salat 4.10.0 → 5.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.
- package/README.md +2 -7
- package/dist/commands/times.js +5 -3
- package/dist/components/CitiesApp.js +2 -2
- package/dist/components/CitySelect.js +22 -0
- package/dist/components/CitySelect.test.js +40 -0
- package/dist/components/HijriApp.js +1 -1
- package/dist/components/ProgressBar.js +7 -0
- package/dist/components/RamadanInfo.js +11 -0
- package/dist/components/TimesApp.js +29 -12
- package/dist/components/TimesApp.test.js +67 -8
- package/dist/components/TimesCommandWrapper.js +10 -0
- package/dist/components/TimesCommandWrapper.test.js +35 -0
- package/dist/data/cities.json +1323 -197
- package/dist/hooks/useHijriDate.js +2 -2
- package/dist/hooks/useHijriDate.test.js +45 -17
- package/dist/hooks/usePrayerTimes.js +9 -14
- package/dist/hooks/usePrayerTimes.test.js +62 -0
- package/dist/services/constants.js +3 -3
- package/dist/services/constants.test.js +20 -0
- package/dist/services/utils/api.js +12 -9
- package/dist/services/utils/api.test.js +24 -13
- package/dist/services/utils/city.js +1 -1
- package/dist/services/utils/city.test.js +39 -37
- package/dist/services/utils/hijri.js +2 -4
- package/dist/services/utils/hijri.test.js +2 -2
- package/dist/services/utils/index.js +1 -2
- package/dist/services/utils/parseHijri.js +34 -0
- package/dist/services/utils/parseHijri.test.js +19 -0
- package/dist/services/utils/time.js +53 -1
- package/dist/services/utils/time.test.js +8 -1
- package/package.json +4 -4
- package/dist/data/prayers.json +0 -20
- package/dist/services/utils/cache.js +0 -28
- package/dist/services/utils/cleanData.js +0 -8
- package/dist/services/utils/cleanData.test.js +0 -59
- package/dist/services/utils/parser.js +0 -20
- package/dist/services/utils/parser.test.js +0 -33
package/README.md
CHANGED
|
@@ -62,9 +62,6 @@ Commands:
|
|
|
62
62
|
hijri Display the current hijri date
|
|
63
63
|
guide Show a rich visual guide to using salat-cli
|
|
64
64
|
cities Display the list of available city names
|
|
65
|
-
help [command] ] [city] Get prayer times for a city
|
|
66
|
-
guide Show a rich visual guide to using salat-cli
|
|
67
|
-
cities Display the list of available city names
|
|
68
65
|
help [command] display help for command
|
|
69
66
|
```
|
|
70
67
|
|
|
@@ -75,13 +72,11 @@ Commands:
|
|
|
75
72
|
## 🏗 Dependencies
|
|
76
73
|
|
|
77
74
|
This project is built on the shoulders of giants:
|
|
78
|
-
|
|
79
|
-
- [**React Query**](https://tanstack.com/query/latest) - Data synchronization library for managing server state.
|
|
80
75
|
- [**Commander.js**](https://github.com/tj/commander) - The complete solution for node.js command-line interfaces.
|
|
76
|
+
- [**Ink**](https://github.com/vadimdemedes/ink) - React for interactive command-line apps.
|
|
77
|
+
- [**React Query**](https://tanstack.com/query/latest) - Data synchronization library for managing server state.
|
|
81
78
|
- [**date-fns**](https://date-fns.org/) - Modern JavaScript date utility library.
|
|
82
|
-
- [**node-fetch**](https://github.com/node-fetch/node-fetch) - A light-weight module that brings `window.fetch` to Node.js.
|
|
83
79
|
- [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API response
|
|
84
|
-
- [**node-localstorage**](https://github.com/lmaccherone/node-localstorage) - LocalStorage implementation for Node.js.
|
|
85
80
|
|
|
86
81
|
## 🤝 Contributing
|
|
87
82
|
|
package/dist/commands/times.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import App from "#components/TimesApp";
|
|
1
|
+
import TimesCommandWrapper from "#components/TimesCommandWrapper";
|
|
3
2
|
import { Command } from "commander";
|
|
4
3
|
import { render } from "ink";
|
|
5
4
|
import React from "react";
|
|
@@ -8,5 +7,8 @@ export const timesCommand = new Command("times")
|
|
|
8
7
|
.argument("[city]", "City name")
|
|
9
8
|
.option("-1, --once", "Run once and exit", false)
|
|
10
9
|
.action((city, options) => {
|
|
11
|
-
render(React.createElement(
|
|
10
|
+
render(React.createElement(TimesCommandWrapper, {
|
|
11
|
+
initialCity: city,
|
|
12
|
+
once: options.once,
|
|
13
|
+
}));
|
|
12
14
|
});
|
|
@@ -4,7 +4,7 @@ import citiesData from "../data/cities.json" with { type: "json" };
|
|
|
4
4
|
const CitiesApp = () => {
|
|
5
5
|
const cities = citiesData;
|
|
6
6
|
// Sort cities alphabetically
|
|
7
|
-
const sortedCities = [...cities].sort((a, b) => a.
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["\uD83C\uDF0D Available Cities in Morocco (", cities.length, ")"] }) }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", children: sortedCities.map((city) => (_jsxs(Box, { width: 25, marginBottom: 0, children: [_jsx(Text, { color: "gray", children: "- " }), _jsx(Text, { children: city.
|
|
7
|
+
const sortedCities = [...cities].sort((a, b) => a.frenchName.localeCompare(b.frenchName));
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["\uD83C\uDF0D Available Cities in Morocco (", cities.length, ")"] }) }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", children: sortedCities.map((city) => (_jsxs(Box, { width: 25, marginBottom: 0, children: [_jsx(Text, { color: "gray", children: "- " }), _jsx(Text, { children: city.frenchName })] }, city.id))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: ["Tip: Use these names with the 'times' command, e.g., 'salat times", " ", sortedCities[0]?.frenchName, "'"] }) })] }));
|
|
9
9
|
};
|
|
10
10
|
export default CitiesApp;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import SelectInput from "ink-select-input";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import citiesData from "../data/cities.json" with { type: "json" };
|
|
7
|
+
const cities = citiesData;
|
|
8
|
+
const CitySelect = ({ onSelect }) => {
|
|
9
|
+
const [query, setQuery] = useState("");
|
|
10
|
+
const filteredCities = useMemo(() => {
|
|
11
|
+
if (!query)
|
|
12
|
+
return cities;
|
|
13
|
+
const lowerQuery = query.toLowerCase();
|
|
14
|
+
return cities.filter((c) => c.frenchName.toLowerCase().includes(lowerQuery));
|
|
15
|
+
}, [query]);
|
|
16
|
+
const items = filteredCities.map((c) => ({
|
|
17
|
+
label: `${c.frenchName}`,
|
|
18
|
+
value: c.frenchName,
|
|
19
|
+
}));
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, width: 60, borderStyle: "round", borderColor: "green", children: [_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 1, children: _jsx(Box, { borderStyle: "single", borderColor: "green", paddingX: 2, children: _jsx(Text, { bold: true, color: "green", children: "SELECT YOUR CITY" }) }) }), _jsxs(Box, { marginBottom: 1, paddingX: 2, children: [_jsx(Text, { color: "white", bold: true, children: "Filter: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "Type city name..." })] }), _jsx(Box, { borderStyle: "single", borderColor: "gray", padding: 1, children: items.length > 0 ? (_jsx(SelectInput, { items: items.sort((a, b) => a.label.localeCompare(b.label)), onSelect: (item) => onSelect(item.value), limit: 8, indicatorComponent: ({ isSelected }) => (_jsx(Text, { color: "yellow", children: isSelected ? "> " : " " })), itemComponent: ({ label, isSelected }) => (_jsx(Text, { color: isSelected ? "yellow" : "white", children: label })) })) : (_jsx(Box, { justifyContent: "center", width: "100%", children: _jsx(Text, { color: "red", children: "No cities found." }) })) }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, color: "gray", children: "Use arrow keys to select \u2022 Enter to confirm" }) })] }));
|
|
21
|
+
};
|
|
22
|
+
export default CitySelect;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import CitySelect from "./CitySelect.js";
|
|
5
|
+
// Mock data
|
|
6
|
+
vi.mock("../data/cities.json", () => ({
|
|
7
|
+
default: [
|
|
8
|
+
{ frenchName: "Casablanca", arabicName: "الدار البيضاء" },
|
|
9
|
+
{ frenchName: "Rabat", arabicName: "الرباط" },
|
|
10
|
+
{ frenchName: "Tangier", arabicName: "طنجة" },
|
|
11
|
+
],
|
|
12
|
+
}));
|
|
13
|
+
describe("CitySelect", () => {
|
|
14
|
+
it("should render input and list", () => {
|
|
15
|
+
const { lastFrame } = render(_jsx(CitySelect, { onSelect: () => { } }));
|
|
16
|
+
expect(lastFrame()).toContain("SELECT YOUR CITY");
|
|
17
|
+
expect(lastFrame()).toContain("Casablanca");
|
|
18
|
+
expect(lastFrame()).toContain("Rabat");
|
|
19
|
+
});
|
|
20
|
+
it("should show no results message when no match", async () => {
|
|
21
|
+
const { lastFrame, stdin } = render(_jsx(CitySelect, { onSelect: () => { } }));
|
|
22
|
+
stdin.write("NonExistentCity");
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
24
|
+
expect(lastFrame()).toContain("No cities found");
|
|
25
|
+
});
|
|
26
|
+
it("should filter cities by french name", async () => {
|
|
27
|
+
const { lastFrame, stdin } = render(_jsx(CitySelect, { onSelect: () => { } }));
|
|
28
|
+
stdin.write("Rab");
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
30
|
+
expect(lastFrame()).toContain("Rabat");
|
|
31
|
+
expect(lastFrame()).not.toContain("Casablanca");
|
|
32
|
+
});
|
|
33
|
+
it("should select a city", () => {
|
|
34
|
+
const onSelect = vi.fn();
|
|
35
|
+
const { stdin, lastFrame } = render(_jsx(CitySelect, { onSelect: onSelect }));
|
|
36
|
+
// Select first item (Casablanca)
|
|
37
|
+
stdin.write("\r");
|
|
38
|
+
expect(onSelect).toHaveBeenCalledWith("Casablanca");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -9,6 +9,6 @@ const HijriApp = () => {
|
|
|
9
9
|
if (error) {
|
|
10
10
|
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
11
11
|
}
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "blue", children: "\uD83D\uDD4C Hijri Date" }) }), _jsx(Box, { padding: 1, children: _jsx(Text, { children:
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "blue", children: "\uD83D\uDD4C Hijri Date" }) }), _jsx(Box, { padding: 1, children: _jsx(Text, { children: `${hijriDate}` }) })] }));
|
|
13
13
|
};
|
|
14
14
|
export default HijriApp;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export const ProgressBar = ({ progress, width }) => {
|
|
4
|
+
const filledWidth = Math.max(0, Math.min(width, Math.round(progress * width)));
|
|
5
|
+
const emptyWidth = width - filledWidth;
|
|
6
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "green", children: "█".repeat(filledWidth) }), _jsx(Text, { color: "gray", children: "░".repeat(emptyWidth) })] }));
|
|
7
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ProgressBar } from "#components/ProgressBar";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
export const RamadanInfo = ({ data }) => {
|
|
5
|
+
if (!data)
|
|
6
|
+
return null;
|
|
7
|
+
if (data.type === "fasting") {
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, color: "yellow", children: "\u231B Iftar Progress" }), _jsx(Box, { marginTop: 1, children: _jsx(ProgressBar, { progress: data.progress, width: 50 }) }), _jsxs(Box, { justifyContent: "space-between", width: 50, children: [_jsx(Text, { color: "gray", dimColor: true, children: "Fajr" }), _jsxs(Box, { children: [_jsxs(Text, { color: "yellow", bold: true, children: [Math.round(data.progress * 100), "%"] }), _jsx(Text, { color: "gray", children: " to Maghrib" })] }), _jsx(Text, { color: "gray", dimColor: true, children: "Iftar" })] })] }));
|
|
9
|
+
}
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, color: "yellow", children: "\uD83C\uDF19 Time to Imsak (Suhoor ends)" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", bold: true, children: data.timeLeft }) }), _jsx(Text, { dimColor: true, color: "gray", children: "until fast begins" })] }));
|
|
11
|
+
};
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ProgressBar } from "#components/ProgressBar";
|
|
3
|
+
import { RamadanInfo } from "#components/RamadanInfo";
|
|
4
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
2
5
|
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
3
|
-
import { getNextPrayer, tConv24 } from "#services/utils/time";
|
|
6
|
+
import { getImsakTime, getNextPrayer, getPrayerProgress, getRamadanData, tConv24 } from "#services/utils/time";
|
|
4
7
|
import { format } from "date-fns";
|
|
5
|
-
import { Box, Text, useApp } from "ink";
|
|
8
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
6
9
|
import { useEffect, useState } from "react";
|
|
7
|
-
const App = ({ cityNameArg, once }) => {
|
|
10
|
+
const App = ({ cityNameArg, once, onReset }) => {
|
|
8
11
|
const { exit } = useApp();
|
|
9
|
-
const { prayerTimes, error, loading, resolvedCityName } = usePrayerTimes({
|
|
12
|
+
const { prayerTimes, tomorrowTimes, error, loading, resolvedCityName } = usePrayerTimes({
|
|
10
13
|
cityNameArg,
|
|
11
14
|
});
|
|
15
|
+
const { hijriDate } = useHijriDate();
|
|
12
16
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
17
|
+
useInput((input) => {
|
|
18
|
+
const key = input.toLowerCase();
|
|
19
|
+
if (key === "c" && onReset) {
|
|
20
|
+
onReset();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
13
23
|
useEffect(() => {
|
|
14
24
|
const timer = setInterval(() => {
|
|
15
25
|
setCurrentTime(new Date());
|
|
@@ -25,17 +35,24 @@ const App = ({ cityNameArg, once }) => {
|
|
|
25
35
|
}
|
|
26
36
|
}, [once, loading, prayerTimes, error, exit]);
|
|
27
37
|
if (loading) {
|
|
28
|
-
return _jsx(Text, { children: "Loading prayer times..." });
|
|
38
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "yellow", children: ["Loading prayer times for ", resolvedCityName, "..."] }) }));
|
|
29
39
|
}
|
|
30
40
|
if (error) {
|
|
31
|
-
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
41
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["Error: ", error] }) }));
|
|
32
42
|
}
|
|
33
43
|
if (!prayerTimes) {
|
|
34
|
-
return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
|
|
44
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "red", bold: true, children: "Could not fetch prayer times." }) }));
|
|
35
45
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
const nextPrayerData = getNextPrayer(prayerTimes, currentTime);
|
|
47
|
+
const progress = getPrayerProgress(prayerTimes, currentTime, nextPrayerData.prayer);
|
|
48
|
+
const ramadanData = getRamadanData(prayerTimes, tomorrowTimes, currentTime);
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, borderStyle: "round", borderColor: "green", width: 60, children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", gap: 1, children: [_jsx(Box, { borderStyle: "single", borderColor: "green", children: _jsxs(Text, { bold: true, color: "green", children: ["\uD83C\uDDF2\uD83C\uDDE6 ", resolvedCityName, ", Morocco \uD83C\uDDF2\uD83C\uDDE6"] }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "white", bold: true, children: ["\uD83D\uDCC5 ", format(currentTime, "EEEE, MMMM do yyyy")] }) }), hijriDate && (_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDD4C ", hijriDate] }))] }), _jsx(Box, { flexDirection: "column", alignItems: "center", marginY: 1, padding: 1, borderStyle: "single", borderColor: "yellow", children: _jsx(RamadanInfo, { data: ramadanData }) }), _jsx(Box, { flexDirection: "column", marginY: 1, children: Object.entries({
|
|
50
|
+
"Imsak *": getImsakTime(prayerTimes.Fajr),
|
|
51
|
+
...prayerTimes,
|
|
52
|
+
}).map(([prayer, time]) => {
|
|
53
|
+
const isNext = prayer === nextPrayerData.prayer;
|
|
54
|
+
const isImsak = prayer === "Imsak *";
|
|
55
|
+
return (_jsxs(Box, { justifyContent: "space-between", paddingX: 2, backgroundColor: isNext ? "green" : undefined, children: [_jsx(Box, { children: _jsxs(Text, { color: isNext ? "white" : (isImsak ? "gray" : "white"), bold: isNext, children: [isNext ? "> " : " ", prayer.padEnd(12)] }) }), _jsx(Box, { children: _jsx(Text, { color: isNext ? "white" : (isImsak ? "gray" : "white"), bold: isNext, children: tConv24(time) }) })] }, prayer));
|
|
56
|
+
}) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(Text, { dimColor: true, color: "gray", italic: true, children: "* Imsak is 10 min before Fajr for safety" }) }), _jsxs(Box, { marginTop: 1, paddingX: 2, paddingY: 1, flexDirection: "column", alignItems: "center", borderStyle: "single", borderColor: "yellow", children: [_jsxs(Text, { children: ["Next: ", _jsx(Text, { color: "green", bold: true, children: nextPrayerData.prayer }), " in ", _jsx(Text, { color: "yellow", bold: true, children: nextPrayerData.timeLeft })] }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(ProgressBar, { progress: progress, width: 50 }), _jsxs(Box, { justifyContent: "space-between", width: 50, marginTop: 0, children: [_jsx(Text, { color: "gray", dimColor: true, children: "0%" }), _jsxs(Text, { color: "green", bold: true, children: [Math.round(progress * 100), "%"] }), _jsx(Text, { color: "gray", dimColor: true, children: "100%" })] })] })] }), !once && (_jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, color: "gray", children: "[C] Change City \u2022 [Ctrl+C] Exit" }) }))] }));
|
|
40
57
|
};
|
|
41
58
|
export default App;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
2
3
|
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
3
4
|
import { render } from "ink-testing-library";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
import TimesApp from "./TimesApp.js";
|
|
6
7
|
vi.mock("#hooks/usePrayerTimes", () => ({
|
|
7
8
|
usePrayerTimes: vi.fn(),
|
|
8
9
|
}));
|
|
10
|
+
vi.mock("#hooks/useHijriDate", () => ({
|
|
11
|
+
useHijriDate: vi.fn(),
|
|
12
|
+
}));
|
|
9
13
|
vi.mock("ink", async () => {
|
|
10
14
|
const actual = await vi.importActual("ink");
|
|
11
15
|
return {
|
|
@@ -16,19 +20,28 @@ vi.mock("ink", async () => {
|
|
|
16
20
|
};
|
|
17
21
|
});
|
|
18
22
|
describe("TimesApp", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
25
|
+
hijriDate: "18 Sha'ban 1447",
|
|
26
|
+
error: null,
|
|
27
|
+
loading: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
19
30
|
it("should render loading state", () => {
|
|
20
31
|
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
21
32
|
prayerTimes: null,
|
|
33
|
+
tomorrowTimes: null,
|
|
22
34
|
error: null,
|
|
23
35
|
loading: true,
|
|
24
|
-
resolvedCityName: "",
|
|
36
|
+
resolvedCityName: "Marrakech",
|
|
25
37
|
});
|
|
26
38
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
27
|
-
expect(lastFrame()).toContain("Loading prayer times
|
|
39
|
+
expect(lastFrame()).toContain("Loading prayer times for Marrakech");
|
|
28
40
|
});
|
|
29
41
|
it("should render error state", () => {
|
|
30
42
|
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
31
43
|
prayerTimes: null,
|
|
44
|
+
tomorrowTimes: null,
|
|
32
45
|
error: "Failed to fetch",
|
|
33
46
|
loading: false,
|
|
34
47
|
resolvedCityName: "",
|
|
@@ -39,6 +52,7 @@ describe("TimesApp", () => {
|
|
|
39
52
|
it("should render null prayerTimes state", () => {
|
|
40
53
|
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
41
54
|
prayerTimes: null,
|
|
55
|
+
tomorrowTimes: null,
|
|
42
56
|
error: null,
|
|
43
57
|
loading: false,
|
|
44
58
|
resolvedCityName: "",
|
|
@@ -57,17 +71,62 @@ describe("TimesApp", () => {
|
|
|
57
71
|
};
|
|
58
72
|
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
59
73
|
prayerTimes: mockPrayerTimes,
|
|
74
|
+
tomorrowTimes: null,
|
|
60
75
|
error: null,
|
|
61
76
|
loading: false,
|
|
62
77
|
resolvedCityName: "Marrakech",
|
|
63
78
|
});
|
|
64
79
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
65
|
-
expect(lastFrame()).toContain("Marrakech
|
|
80
|
+
expect(lastFrame()).toContain("Marrakech");
|
|
66
81
|
expect(lastFrame()).toContain("Fajr");
|
|
67
82
|
expect(lastFrame()).toContain("05:00 AM");
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
});
|
|
84
|
+
it("should render Iftar progress when fasting", () => {
|
|
85
|
+
const mockPrayerTimes = {
|
|
86
|
+
Fajr: "05:00",
|
|
87
|
+
Chorouq: "06:30",
|
|
88
|
+
Dhuhr: "12:30",
|
|
89
|
+
Asr: "15:45",
|
|
90
|
+
Maghrib: "18:20",
|
|
91
|
+
Ishae: "19:50",
|
|
92
|
+
};
|
|
93
|
+
// Use a fixed date for the test
|
|
94
|
+
const mockNow = new Date("2026-02-18T12:00:00");
|
|
95
|
+
vi.setSystemTime(mockNow);
|
|
96
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
97
|
+
prayerTimes: mockPrayerTimes,
|
|
98
|
+
tomorrowTimes: mockPrayerTimes,
|
|
99
|
+
error: null,
|
|
100
|
+
loading: false,
|
|
101
|
+
resolvedCityName: "Marrakech",
|
|
102
|
+
});
|
|
103
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
104
|
+
expect(lastFrame()).toContain("Iftar Progress");
|
|
105
|
+
// (12:00 - 05:00) / (18:20 - 05:00) = 7h / 13h 20m = 420m / 800m = 0.525 = 53%
|
|
106
|
+
expect(lastFrame()).toContain("53%");
|
|
107
|
+
});
|
|
108
|
+
it("should render Time to Imsak when not fasting (after Maghrib)", () => {
|
|
109
|
+
const mockPrayerTimes = {
|
|
110
|
+
Fajr: "05:00",
|
|
111
|
+
Chorouq: "06:30",
|
|
112
|
+
Dhuhr: "12:30",
|
|
113
|
+
Asr: "15:45",
|
|
114
|
+
Maghrib: "18:20",
|
|
115
|
+
Ishae: "19:50",
|
|
116
|
+
};
|
|
117
|
+
// Use a fixed date for the test (9:00 PM)
|
|
118
|
+
const mockNow = new Date("2026-02-18T21:00:00");
|
|
119
|
+
vi.setSystemTime(mockNow);
|
|
120
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
121
|
+
prayerTimes: mockPrayerTimes,
|
|
122
|
+
tomorrowTimes: mockPrayerTimes, // Tomorrow's Fajr 05:00 -> Imsak 04:50
|
|
123
|
+
error: null,
|
|
124
|
+
loading: false,
|
|
125
|
+
resolvedCityName: "Marrakech",
|
|
126
|
+
});
|
|
127
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
128
|
+
expect(lastFrame()).toContain("Time to Imsak");
|
|
129
|
+
// From 21:00 to 04:50 is 7 hours and 50 minutes
|
|
130
|
+
expect(lastFrame()).toContain("07:50:00");
|
|
72
131
|
});
|
|
73
132
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import CitySelect from "#components/CitySelect";
|
|
3
|
+
import { QueryProvider } from "#components/QueryProvider";
|
|
4
|
+
import App from "#components/TimesApp";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
const TimesCommandWrapper = ({ initialCity, once, }) => {
|
|
7
|
+
const [city, setCity] = useState(initialCity);
|
|
8
|
+
return (_jsx(QueryProvider, { children: city ? (_jsx(App, { cityNameArg: city, once: once, onReset: () => setCity(undefined) })) : (_jsx(CitySelect, { onSelect: setCity })) }));
|
|
9
|
+
};
|
|
10
|
+
export default TimesCommandWrapper;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import TimesCommandWrapper from "./TimesCommandWrapper.js";
|
|
5
|
+
// Mock dependencies
|
|
6
|
+
vi.mock("#components/CitySelect", async () => {
|
|
7
|
+
const { Text } = await import("ink");
|
|
8
|
+
return {
|
|
9
|
+
default: ({ onSelect }) => {
|
|
10
|
+
// Trigger onSelect immediately? No, we need to test rendering.
|
|
11
|
+
return _jsx(Text, { children: "CitySelect Mock" });
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
vi.mock("#components/TimesApp", async () => {
|
|
16
|
+
const { Text } = await import("ink");
|
|
17
|
+
return {
|
|
18
|
+
default: ({ cityNameArg }) => (_jsxs(Text, { children: ["TimesApp Mock: ", cityNameArg] })),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
vi.mock("#components/QueryProvider", () => ({
|
|
22
|
+
QueryProvider: ({ children }) => (_jsx(_Fragment, { children: children })),
|
|
23
|
+
}));
|
|
24
|
+
describe("TimesCommandWrapper", () => {
|
|
25
|
+
it("should render TimesApp if initialCity is provided", () => {
|
|
26
|
+
const { lastFrame } = render(_jsx(TimesCommandWrapper, { initialCity: "Casablanca" }));
|
|
27
|
+
expect(lastFrame()).toContain("TimesApp Mock: Casablanca");
|
|
28
|
+
expect(lastFrame()).not.toContain("CitySelect Mock");
|
|
29
|
+
});
|
|
30
|
+
it("should render CitySelect if no initialCity provided", () => {
|
|
31
|
+
const { lastFrame } = render(_jsx(TimesCommandWrapper, {}));
|
|
32
|
+
expect(lastFrame()).toContain("CitySelect Mock");
|
|
33
|
+
expect(lastFrame()).not.toContain("TimesApp Mock");
|
|
34
|
+
});
|
|
35
|
+
});
|