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.
Files changed (37) hide show
  1. package/README.md +2 -7
  2. package/dist/commands/times.js +5 -3
  3. package/dist/components/CitiesApp.js +2 -2
  4. package/dist/components/CitySelect.js +22 -0
  5. package/dist/components/CitySelect.test.js +40 -0
  6. package/dist/components/HijriApp.js +1 -1
  7. package/dist/components/ProgressBar.js +7 -0
  8. package/dist/components/RamadanInfo.js +11 -0
  9. package/dist/components/TimesApp.js +29 -12
  10. package/dist/components/TimesApp.test.js +67 -8
  11. package/dist/components/TimesCommandWrapper.js +10 -0
  12. package/dist/components/TimesCommandWrapper.test.js +35 -0
  13. package/dist/data/cities.json +1323 -197
  14. package/dist/hooks/useHijriDate.js +2 -2
  15. package/dist/hooks/useHijriDate.test.js +45 -17
  16. package/dist/hooks/usePrayerTimes.js +9 -14
  17. package/dist/hooks/usePrayerTimes.test.js +62 -0
  18. package/dist/services/constants.js +3 -3
  19. package/dist/services/constants.test.js +20 -0
  20. package/dist/services/utils/api.js +12 -9
  21. package/dist/services/utils/api.test.js +24 -13
  22. package/dist/services/utils/city.js +1 -1
  23. package/dist/services/utils/city.test.js +39 -37
  24. package/dist/services/utils/hijri.js +2 -4
  25. package/dist/services/utils/hijri.test.js +2 -2
  26. package/dist/services/utils/index.js +1 -2
  27. package/dist/services/utils/parseHijri.js +34 -0
  28. package/dist/services/utils/parseHijri.test.js +19 -0
  29. package/dist/services/utils/time.js +53 -1
  30. package/dist/services/utils/time.test.js +8 -1
  31. package/package.json +4 -4
  32. package/dist/data/prayers.json +0 -20
  33. package/dist/services/utils/cache.js +0 -28
  34. package/dist/services/utils/cleanData.js +0 -8
  35. package/dist/services/utils/cleanData.test.js +0 -59
  36. package/dist/services/utils/parser.js +0 -20
  37. 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
 
@@ -1,5 +1,4 @@
1
- import { QueryProvider } from "#components/QueryProvider";
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(QueryProvider, undefined, React.createElement(App, { cityNameArg: city, once: options.once })));
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.name.localeCompare(b.name));
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.name })] }, 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]?.name, "'"] }) })] }));
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: `\u061C${hijriDate}` }) })] }));
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
- 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 ", format(currentTime, "PPPP")] }) }), prayerTimes && (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Next Prayer:", " "] }), _jsx(Text, { bold: true, children: getNextPrayer(prayerTimes, currentTime).prayer })] }), _jsxs(Box, { children: [_jsx(Text, { children: "Time: " }), _jsx(Text, { children: tConv24(getNextPrayer(prayerTimes, currentTime).time) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "Remaining: " }), _jsx(Text, { color: "yellow", children: getNextPrayer(prayerTimes, currentTime).timeLeft })] })] })), _jsx(Box, { flexDirection: "column", children: Object.entries(prayerTimes).map(([prayer, time]) => {
37
- const isNext = prayer === getNextPrayer(prayerTimes, currentTime).prayer;
38
- return (_jsxs(Box, { children: [_jsx(Box, { width: 10, children: _jsx(Text, { color: isNext ? "cyan" : "white", bold: isNext, children: prayer }) }), _jsx(Box, { marginRight: 2, children: _jsx(Text, { color: isNext ? "cyan" : "gray", children: "-->" }) }), _jsx(Text, { color: isNext ? "yellow" : "green", bold: isNext, children: tConv24(time) })] }, prayer));
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, Morocco");
80
+ expect(lastFrame()).toContain("Marrakech");
66
81
  expect(lastFrame()).toContain("Fajr");
67
82
  expect(lastFrame()).toContain("05:00 AM");
68
- // Check for prayer times rendering
69
- expect(lastFrame()).toContain("Next Prayer");
70
- expect(lastFrame()).toContain("Dhuhr");
71
- expect(lastFrame()).toContain("12:30 PM");
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
+ });