salat 4.9.5 → 5.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/README.md +20 -16
- package/dist/commands/hijri.js +2 -1
- package/dist/commands/times.js +5 -2
- 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 +3 -21
- package/dist/components/HijriApp.test.js +20 -12
- package/dist/components/QueryProvider.js +6 -0
- package/dist/components/TimesApp.js +64 -14
- package/dist/components/TimesApp.test.js +47 -32
- 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/index.js +2 -0
- package/dist/hooks/useHijriDate.js +18 -0
- package/dist/hooks/useHijriDate.test.js +53 -0
- package/dist/hooks/usePrayerTimes.js +25 -0
- package/dist/hooks/usePrayerTimes.test.js +62 -0
- package/dist/lib/queryClient.js +9 -0
- package/dist/services/constants.js +3 -4
- package/dist/services/constants.test.js +20 -0
- package/dist/services/utils/api.js +11 -8
- 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 +3 -5
- package/dist/services/utils/hijri.test.js +2 -2
- package/dist/services/utils/index.js +1 -1
- package/dist/services/utils/parseHijri.js +34 -0
- package/dist/services/utils/parseHijri.test.js +19 -0
- package/dist/services/utils/time.js +6 -1
- package/dist/services/utils/time.test.js +8 -1
- package/package.json +5 -6
- package/dist/data/prayers.json +0 -20
- package/dist/hooks/useSalat.js +0 -74
- package/dist/services/utils/parser.js +0 -20
- package/dist/services/utils/parser.test.js +0 -33
package/README.md
CHANGED
|
@@ -13,10 +13,11 @@ A modern, visually rich CLI for checking prayer times in Morocco, built with **R
|
|
|
13
13
|
## 🚀 Features
|
|
14
14
|
|
|
15
15
|
- **Live Countdown**: "Remaining" time updates every second in real-time.
|
|
16
|
+
- **Hijri Date**: Display the current Hijri date alongside Gregorian dates.
|
|
16
17
|
- **Rich UI**: Beautiful terminal interface with colors and borders.
|
|
17
18
|
- **Morocco Focused**: Supports 190+ cities across Morocco.
|
|
18
|
-
- **Smart Caching**:
|
|
19
|
-
- **Developer Friendly**: Built with TypeScript
|
|
19
|
+
- **Smart Caching**: In-memory caching with React Query to minimize API calls.
|
|
20
|
+
- **Developer Friendly**: Built with TypeScript, Commander.js, and React.
|
|
20
21
|
|
|
21
22
|
## 📦 Installation
|
|
22
23
|
|
|
@@ -36,7 +37,10 @@ salat
|
|
|
36
37
|
salat times Rabat
|
|
37
38
|
|
|
38
39
|
# Run once and exit (no live timer)
|
|
39
|
-
salat -1
|
|
40
|
+
salat times Casablanca -1
|
|
41
|
+
|
|
42
|
+
# Get the current hijri date
|
|
43
|
+
salat hijri
|
|
40
44
|
|
|
41
45
|
# Show a rich visual guide
|
|
42
46
|
salat guide
|
|
@@ -50,29 +54,29 @@ salat cities
|
|
|
50
54
|
```text
|
|
51
55
|
Usage: salat [options] [command]
|
|
52
56
|
|
|
53
|
-
Daily Moroccan prayers
|
|
54
|
-
|
|
55
|
-
Options:
|
|
56
|
-
-V, --version output the version number
|
|
57
|
-
-h, --help display help for command
|
|
57
|
+
Daily Moroccan prayers tim output the version number
|
|
58
|
+
-h, --help display help for command
|
|
58
59
|
|
|
59
60
|
Commands:
|
|
60
|
-
times [options] [city]
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
times [options] [city] Get prayer times for a city
|
|
62
|
+
hijri Display the current hijri date
|
|
63
|
+
guide Show a rich visual guide to using salat-cli
|
|
64
|
+
cities Display the list of available city names
|
|
63
65
|
help [command] display help for command
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
## 📸 Screenshots
|
|
69
|
+
|
|
70
|
+

|
|
71
|
+
|
|
66
72
|
## 🏗 Dependencies
|
|
67
73
|
|
|
68
74
|
This project is built on the shoulders of giants:
|
|
69
|
-
|
|
70
|
-
- [**Ink**](https://github.com/vadimdemedes/ink) - React for interactive command-line apps.
|
|
71
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.
|
|
72
78
|
- [**date-fns**](https://date-fns.org/) - Modern JavaScript date utility library.
|
|
73
|
-
- [**
|
|
74
|
-
- [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API responses.
|
|
75
|
-
- [**node-localstorage**](https://github.com/lmaccherone/node-localstorage) - LocalStorage implementation for Node.js.
|
|
79
|
+
- [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API response
|
|
76
80
|
|
|
77
81
|
## 🤝 Contributing
|
|
78
82
|
|
package/dist/commands/hijri.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { QueryProvider } from "#components/QueryProvider";
|
|
1
2
|
import HijriApp from "#components/HijriApp";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { render } from "ink";
|
|
@@ -6,5 +7,5 @@ export const hijriCommand = new Command("hijri")
|
|
|
6
7
|
.description("Display the hijri date")
|
|
7
8
|
.option("-1, --once", "Run once and exit", false)
|
|
8
9
|
.action(() => {
|
|
9
|
-
render(React.createElement(HijriApp));
|
|
10
|
+
render(React.createElement(QueryProvider, undefined, React.createElement(HijriApp)));
|
|
10
11
|
});
|
package/dist/commands/times.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import TimesCommandWrapper from "#components/TimesCommandWrapper";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { render } from "ink";
|
|
4
4
|
import React from "react";
|
|
@@ -7,5 +7,8 @@ export const timesCommand = new Command("times")
|
|
|
7
7
|
.argument("[city]", "City name")
|
|
8
8
|
.option("-1, --once", "Run once and exit", false)
|
|
9
9
|
.action((city, options) => {
|
|
10
|
-
render(React.createElement(
|
|
10
|
+
render(React.createElement(TimesCommandWrapper, {
|
|
11
|
+
initialCity: city,
|
|
12
|
+
once: options.once,
|
|
13
|
+
}));
|
|
11
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
|
+
});
|
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
5
4
|
const HijriApp = () => {
|
|
6
|
-
const
|
|
7
|
-
const [error, setError] = useState(null);
|
|
8
|
-
const [loading, setLoading] = useState(true);
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
const fetchHijri = async () => {
|
|
11
|
-
try {
|
|
12
|
-
const result = await getHijriDate();
|
|
13
|
-
setHijriDate(result.date);
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
setError(err.message || "Failed to fetch hijri date");
|
|
17
|
-
}
|
|
18
|
-
finally {
|
|
19
|
-
setLoading(false);
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
fetchHijri();
|
|
23
|
-
}, []);
|
|
5
|
+
const { hijriDate, error, loading } = useHijriDate();
|
|
24
6
|
if (loading) {
|
|
25
7
|
return _jsx(Text, { children: "Loading hijri date..." });
|
|
26
8
|
}
|
|
27
9
|
if (error) {
|
|
28
10
|
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
29
11
|
}
|
|
30
|
-
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}` }) })] }));
|
|
31
13
|
};
|
|
32
14
|
export default HijriApp;
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
3
|
import { render } from "ink-testing-library";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import HijriApp from "./HijriApp.js";
|
|
6
|
-
vi.mock("#
|
|
7
|
-
|
|
6
|
+
vi.mock("#hooks/useHijriDate", () => ({
|
|
7
|
+
useHijriDate: vi.fn(),
|
|
8
8
|
}));
|
|
9
9
|
describe("HijriApp", () => {
|
|
10
10
|
it("should render loading state", () => {
|
|
11
|
-
vi.mocked(
|
|
11
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
12
|
+
hijriDate: null,
|
|
13
|
+
error: null,
|
|
14
|
+
loading: true,
|
|
15
|
+
});
|
|
12
16
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
13
17
|
expect(lastFrame()).toContain("Loading hijri date...");
|
|
14
18
|
});
|
|
15
|
-
it("should render error state",
|
|
16
|
-
vi.mocked(
|
|
19
|
+
it("should render error state", () => {
|
|
20
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
21
|
+
hijriDate: null,
|
|
22
|
+
error: "Network error",
|
|
23
|
+
loading: false,
|
|
24
|
+
});
|
|
17
25
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
18
|
-
// Wait for async operation
|
|
19
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
20
26
|
expect(lastFrame()).toContain("Error: Network error");
|
|
21
27
|
});
|
|
22
|
-
it("should render hijri date",
|
|
28
|
+
it("should render hijri date", () => {
|
|
23
29
|
const mockDate = "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م";
|
|
24
|
-
vi.mocked(
|
|
30
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
31
|
+
hijriDate: mockDate,
|
|
32
|
+
error: null,
|
|
33
|
+
loading: false,
|
|
34
|
+
});
|
|
25
35
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
26
|
-
// Wait for async operation
|
|
27
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
36
|
expect(lastFrame()).toContain("Hijri Date");
|
|
29
37
|
expect(lastFrame()).toContain(mockDate);
|
|
30
38
|
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { queryClient } from "#lib/queryClient";
|
|
3
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
export const QueryProvider = ({ children }) => {
|
|
5
|
+
return (_jsx(QueryClientProvider, { client: queryClient, children: children }));
|
|
6
|
+
};
|
|
@@ -1,25 +1,75 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
|
+
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
4
|
+
import { getImsakTime, getNextPrayer, tConv24 } from "#services/utils/time";
|
|
5
|
+
import { differenceInSeconds, format, parse, subDays } from "date-fns";
|
|
6
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
const ProgressBar = ({ progress, width, }) => {
|
|
9
|
+
const filledWidth = Math.max(0, Math.min(width, Math.round(progress * width)));
|
|
10
|
+
const emptyWidth = width - filledWidth;
|
|
11
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "green", children: "█".repeat(filledWidth) }), _jsx(Text, { color: "gray", children: "░".repeat(emptyWidth) })] }));
|
|
12
|
+
};
|
|
13
|
+
const App = ({ cityNameArg, once, onReset }) => {
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const { prayerTimes, error, loading, resolvedCityName } = usePrayerTimes({
|
|
8
16
|
cityNameArg,
|
|
9
|
-
once,
|
|
10
17
|
});
|
|
18
|
+
const { hijriDate } = useHijriDate();
|
|
19
|
+
const [currentTime, setCurrentTime] = useState(new Date());
|
|
20
|
+
useInput((input) => {
|
|
21
|
+
const key = input.toLowerCase();
|
|
22
|
+
if (key === "c" && onReset) {
|
|
23
|
+
onReset();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const timer = setInterval(() => {
|
|
28
|
+
setCurrentTime(new Date());
|
|
29
|
+
}, 1000);
|
|
30
|
+
return () => clearInterval(timer);
|
|
31
|
+
}, []);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (once && !loading && (prayerTimes || error)) {
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
exit();
|
|
36
|
+
}, 100);
|
|
37
|
+
return () => clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}, [once, loading, prayerTimes, error, exit]);
|
|
11
40
|
if (loading) {
|
|
12
|
-
return _jsx(Text, { children: "Loading prayer times..." });
|
|
41
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "yellow", children: ["Loading prayer times for ", resolvedCityName, "..."] }) }));
|
|
13
42
|
}
|
|
14
43
|
if (error) {
|
|
15
|
-
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
44
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["Error: ", error] }) }));
|
|
16
45
|
}
|
|
17
46
|
if (!prayerTimes) {
|
|
18
|
-
return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
|
|
47
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "red", bold: true, children: "Could not fetch prayer times." }) }));
|
|
48
|
+
}
|
|
49
|
+
const nextPrayerData = getNextPrayer(prayerTimes, currentTime);
|
|
50
|
+
// Calculate progress
|
|
51
|
+
const prayerOrder = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
|
|
52
|
+
const nextIndex = prayerOrder.indexOf(nextPrayerData.prayer);
|
|
53
|
+
const prevIndex = (nextIndex - 1 + prayerOrder.length) % prayerOrder.length;
|
|
54
|
+
const prevPrayerName = prayerOrder[prevIndex];
|
|
55
|
+
let prevDate = parse(prayerTimes[prevPrayerName], "HH:mm", currentTime);
|
|
56
|
+
let nextDate = parse(nextPrayerData.time, "HH:mm", currentTime);
|
|
57
|
+
if (nextPrayerData.prayer === "Fajr" && currentTime.getHours() >= 12) {
|
|
58
|
+
nextDate = parse(nextPrayerData.time, "HH:mm", new Date(currentTime.getTime() + 24 * 60 * 60 * 1000));
|
|
59
|
+
}
|
|
60
|
+
else if (nextPrayerData.prayer === "Fajr" && currentTime.getHours() < 12) {
|
|
61
|
+
prevDate = subDays(parse(prayerTimes.Ishae, "HH:mm", currentTime), 1);
|
|
19
62
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
63
|
+
const totalSeconds = differenceInSeconds(nextDate, prevDate);
|
|
64
|
+
const elapsedSeconds = differenceInSeconds(currentTime, prevDate);
|
|
65
|
+
const progress = Math.max(0, Math.min(1, elapsedSeconds / totalSeconds));
|
|
66
|
+
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", marginY: 1, children: Object.entries({
|
|
67
|
+
"Imsak *": getImsakTime(prayerTimes.Fajr),
|
|
68
|
+
...prayerTimes,
|
|
69
|
+
}).map(([prayer, time]) => {
|
|
70
|
+
const isNext = prayer === nextPrayerData.prayer;
|
|
71
|
+
const isImsak = prayer === "Imsak *";
|
|
72
|
+
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));
|
|
73
|
+
}) }), _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" }) }))] }));
|
|
24
74
|
};
|
|
25
75
|
export default App;
|
|
@@ -1,65 +1,80 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
|
+
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
4
|
+
import { render } from "ink-testing-library";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import TimesApp from "./TimesApp.js";
|
|
7
|
+
vi.mock("#hooks/usePrayerTimes", () => ({
|
|
8
|
+
usePrayerTimes: vi.fn(),
|
|
8
9
|
}));
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
vi.mock("#hooks/useHijriDate", () => ({
|
|
11
|
+
useHijriDate: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("ink", async () => {
|
|
14
|
+
const actual = await vi.importActual("ink");
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
useApp: vi.fn(() => ({
|
|
18
|
+
exit: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
describe("TimesApp", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
25
|
+
hijriDate: "18 Sha'ban 1447",
|
|
26
|
+
error: null,
|
|
27
|
+
loading: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it("should render loading state", () => {
|
|
31
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
12
32
|
prayerTimes: null,
|
|
13
33
|
error: null,
|
|
14
34
|
loading: true,
|
|
15
|
-
resolvedCityName:
|
|
16
|
-
currentTime: new Date()
|
|
35
|
+
resolvedCityName: "Marrakech",
|
|
17
36
|
});
|
|
18
37
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
19
|
-
expect(lastFrame()).toContain(
|
|
38
|
+
expect(lastFrame()).toContain("Loading prayer times for Marrakech");
|
|
20
39
|
});
|
|
21
|
-
it(
|
|
22
|
-
|
|
40
|
+
it("should render error state", () => {
|
|
41
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
23
42
|
prayerTimes: null,
|
|
24
|
-
error:
|
|
43
|
+
error: "Failed to fetch",
|
|
25
44
|
loading: false,
|
|
26
|
-
resolvedCityName:
|
|
27
|
-
currentTime: new Date()
|
|
45
|
+
resolvedCityName: "",
|
|
28
46
|
});
|
|
29
47
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
30
|
-
expect(lastFrame()).toContain(
|
|
48
|
+
expect(lastFrame()).toContain("Error: Failed to fetch");
|
|
31
49
|
});
|
|
32
|
-
it(
|
|
33
|
-
|
|
50
|
+
it("should render null prayerTimes state", () => {
|
|
51
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
34
52
|
prayerTimes: null,
|
|
35
53
|
error: null,
|
|
36
54
|
loading: false,
|
|
37
|
-
resolvedCityName:
|
|
38
|
-
currentTime: new Date()
|
|
55
|
+
resolvedCityName: "",
|
|
39
56
|
});
|
|
40
57
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
41
|
-
expect(lastFrame()).toContain(
|
|
58
|
+
expect(lastFrame()).toContain("Could not fetch prayer times.");
|
|
42
59
|
});
|
|
43
|
-
it(
|
|
60
|
+
it("should render prayer times", () => {
|
|
44
61
|
const mockPrayerTimes = {
|
|
45
62
|
Fajr: "05:00",
|
|
46
63
|
Chorouq: "06:30",
|
|
47
64
|
Dhuhr: "12:30",
|
|
48
65
|
Asr: "15:45",
|
|
49
66
|
Maghrib: "18:20",
|
|
50
|
-
Ishae: "19:50"
|
|
67
|
+
Ishae: "19:50",
|
|
51
68
|
};
|
|
52
|
-
|
|
69
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
53
70
|
prayerTimes: mockPrayerTimes,
|
|
54
71
|
error: null,
|
|
55
72
|
loading: false,
|
|
56
|
-
resolvedCityName:
|
|
57
|
-
currentTime: new Date('2026-02-07T10:00:00')
|
|
73
|
+
resolvedCityName: "Marrakech",
|
|
58
74
|
});
|
|
59
75
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
60
|
-
expect(lastFrame()).toContain(
|
|
61
|
-
expect(lastFrame()).toContain(
|
|
62
|
-
expect(lastFrame()).toContain(
|
|
63
|
-
expect(lastFrame()).toContain('Next Prayer: Dhuhr');
|
|
76
|
+
expect(lastFrame()).toContain("Marrakech");
|
|
77
|
+
expect(lastFrame()).toContain("Fajr");
|
|
78
|
+
expect(lastFrame()).toContain("05:00 AM");
|
|
64
79
|
});
|
|
65
80
|
});
|
|
@@ -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
|
+
});
|