Skip to content

Commit 3af2d0f

Browse files
feat: implement news source filtering and enhance UI components
Added a source picker to the News component, allowing users to filter news by source. Updated the HeadlineRow component to conditionally display the source name based on the selected filter. Enhanced the overall layout and styling of the news section, including improved handling of empty states and loading indicators. Updated translations for new UI elements and added a new type for news sources to streamline data handling.
1 parent 43d5b2f commit 3af2d0f

13 files changed

Lines changed: 201 additions & 52 deletions

File tree

apps/desktop/src-tauri/src/commands/news.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use chrono::Utc;
44
use sajilo_api::load_state::LoadState;
5-
use sajilo_api::news::NewsDigest;
5+
use sajilo_api::news::{NewsDigest, NewsSourceInfo};
66
use sajilo_providers::{HttpClient, rss};
77
use tauri::{AppHandle, Manager, Wry};
88

@@ -45,3 +45,9 @@ pub async fn get_news(app: AppHandle<Wry>, refresh: Option<bool>) -> LoadState<N
4545
})
4646
.await
4747
}
48+
49+
/// The source picker's options. Static — no network, no cache.
50+
#[tauri::command]
51+
pub fn news_sources() -> Vec<NewsSourceInfo> {
52+
NewsSourceInfo::catalog()
53+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ pub fn run() {
142142
commands::weather::get_weather,
143143
commands::forex::get_forex,
144144
commands::news::get_news,
145+
commands::news::news_sources,
145146
commands::calendar::today,
146147
commands::calendar::month_grid,
147148
commands::calendar::shift_month,

apps/desktop/src/features/calendar/_components/glance-cards.tsx

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { useEffect, useState } from "react";
22
import { useNavigate } from "react-router";
33
import { Icon } from "../../../shared/components/icon";
44
import { Pressable } from "../../../shared/components/motion";
5-
import { Sparkline } from "../../../shared/components/sparkline";
65
import { useSettings } from "../../../shared/context/settings-context";
7-
import { api, type Bazar } from "../../../shared/lib/ipc";
6+
import { api } from "../../../shared/lib/ipc";
87
import { loadedValue } from "../../../shared/lib/load-state";
98
import type { LoadState } from "../../../types/api/LoadState";
9+
import type { StockMarketSnapshot } from "../../../types/api/StockMarketSnapshot";
1010
import type { WeatherLocation } from "../../../types/api/WeatherLocation";
1111
import type { WeatherSnapshot } from "../../../types/api/WeatherSnapshot";
12-
import { headlineMetal, money0, priceChange } from "../../bazar/_lib/format";
12+
import { money } from "../../bazar/_lib/format";
13+
import { changeTone } from "../../bazar/_lib/stock-tone";
1314
import { conditionTitle, formatCelsius } from "../../weather/_lib/format";
1415

1516
const CITY: Record<WeatherLocation, { en: string; ne: string }> = {
@@ -44,7 +45,7 @@ export function GlanceCards() {
4445
const { language, modules, t } = useSettings();
4546
const navigate = useNavigate();
4647
const [weather, setWeather] = useState<LoadState<WeatherSnapshot>>();
47-
const [bazar, setBazar] = useState<Bazar>();
48+
const [stocks, setStocks] = useState<LoadState<StockMarketSnapshot>>();
4849

4950
useEffect(() => {
5051
if (!modules.weatherEnabled) return;
@@ -57,17 +58,16 @@ export function GlanceCards() {
5758
useEffect(() => {
5859
if (!modules.bazarEnabled) return;
5960
api
60-
.getBazar()
61-
.then(setBazar)
61+
.getStocks()
62+
.then(setStocks)
6263
.catch(() => {});
6364
}, [modules.bazarEnabled]);
6465

6566
const weatherSnap = loadedValue(weather);
66-
const metalsSnap = loadedValue(bazar?.metals);
67-
const headline = metalsSnap && headlineMetal(metalsSnap);
68-
const change = headline ? priceChange(headline.price, headline.previousPrice) : 0;
67+
const stocksSnap = loadedValue(stocks);
68+
const nepse = stocksSnap?.nepse ?? null;
6969
const freshness = relativeFreshness(
70-
weatherSnap?.freshness.fetchedAt ?? metalsSnap?.freshness.fetchedAt,
70+
weatherSnap?.freshness.fetchedAt ?? stocksSnap?.freshness.fetchedAt,
7171
t,
7272
);
7373

@@ -107,33 +107,27 @@ export function GlanceCards() {
107107
<Pressable className="min-w-0 flex-1">
108108
<button
109109
type="button"
110-
onClick={() => navigate("/bazar?tab=metals")}
111-
className="surface-card glance-card glance-metal relative flex min-h-[72px] w-full flex-col p-2.5 text-left"
110+
onClick={() => navigate("/bazar?tab=stocks")}
111+
className="surface-card glance-card glance-market relative flex min-h-[72px] w-full flex-col p-2.5 text-left"
112112
>
113113
<div className="relative z-[1] flex items-center gap-1 text-text-muted">
114-
<Icon name="gold" className="size-3 text-[color:var(--color-accent-mark)]" />
115-
<span className="text-[10px]">{t("dashboard.gold")}</span>
114+
<Icon name="interest" className="size-3 text-[color:var(--color-accent-mark)]" />
115+
<span className="text-[10px]">{t("dashboard.nepse")}</span>
116116
</div>
117117
<p className="relative z-[1] mt-0.5 text-[18px] font-semibold leading-none tabular-nums">
118-
{headline
119-
? `${language === "ne" ? "रु" : "Rs"} ${money0.format(headline.price)}`
120-
: "…"}
118+
{nepse ? money.format(nepse.value) : "…"}
121119
</p>
122-
<p className="relative z-[1] mt-1 truncate text-[10px] text-text-muted">
123-
{headline
124-
? change === 0
125-
? t("dashboard.gold-no-change")
126-
: `${change > 0 ? "↑" : "↓"} ${language === "ne" ? "रु" : "Rs"} ${money0.format(Math.abs(change))} ${t("dashboard.today")}`
120+
<p
121+
className={`relative z-[1] mt-1 truncate text-[10px] tabular-nums ${
122+
nepse && nepse.change !== 0 ? changeTone(nepse.change) : "text-text-muted"
123+
}`}
124+
>
125+
{nepse
126+
? nepse.change === 0
127+
? t("dashboard.nepse-no-change")
128+
: `${nepse.change > 0 ? "↑" : "↓"} ${money.format(Math.abs(nepse.change))} · ${Math.abs(nepse.changePercent).toFixed(2)}%`
127129
: "—"}
128130
</p>
129-
{metalsSnap?.goldHistory && (
130-
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[1] px-1 pb-0.5">
131-
<Sparkline
132-
values={metalsSnap.goldHistory}
133-
className={change >= 0 ? "text-positive" : "text-holiday"}
134-
/>
135-
</div>
136-
)}
137131
</button>
138132
</Pressable>
139133
)}

apps/desktop/src/features/news/_components/headline-row.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ function age(item: NewsItem): string {
1818
);
1919
}
2020

21-
export function HeadlineRow({ item, onOpen }: { item: NewsItem; onOpen: () => void }) {
21+
export function HeadlineRow({
22+
item,
23+
showSource = true,
24+
onOpen,
25+
}: {
26+
item: NewsItem;
27+
/** False when the list is already filtered to one publisher. */
28+
showSource?: boolean;
29+
onOpen: () => void;
30+
}) {
2231
return (
2332
<button
2433
type="button"
@@ -27,10 +36,14 @@ export function HeadlineRow({ item, onOpen }: { item: NewsItem; onOpen: () => vo
2736
>
2837
<p className="text-[13px] leading-snug">{item.title}</p>
2938
<div className="mt-1 flex items-center gap-1 text-[10px]">
30-
<span className="font-medium text-[color:var(--color-accent-mark)]">{item.sourceName}</span>
39+
{showSource && (
40+
<span className="font-medium text-[color:var(--color-accent-mark)]">
41+
{item.sourceName}
42+
</span>
43+
)}
3144
{item.published && (
3245
<>
33-
<span className="text-text-muted">·</span>
46+
{showSource && <span className="text-text-muted">·</span>}
3447
<span className="text-text-secondary">{age(item)}</span>
3548
</>
3649
)}

apps/desktop/src/features/news/news.tsx

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useCallback, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import useSWR from "swr";
33
import { useHeaderSlot } from "../../shared/components/header-slot";
44
import { Icon } from "../../shared/components/icon";
55
import { FadeUp, Stagger } from "../../shared/components/motion";
6+
import { Select } from "../../shared/components/select";
67
import { StateBanner } from "../../shared/components/state-banner";
78
import { useSettings } from "../../shared/context/settings-context";
89
import { openExternalLink } from "../../shared/lib/external-link";
@@ -13,14 +14,23 @@ import {
1314
loadBanner,
1415
loadedValue,
1516
} from "../../shared/lib/load-state";
17+
import { usePersistedString } from "../../shared/lib/persisted";
1618
import type { NewsDigest } from "../../types/api/NewsDigest";
19+
import type { NewsSourceInfo } from "../../types/api/NewsSourceInfo";
1720
import { HeadlineRow } from "./_components/headline-row";
1821

1922
const PAGE = 20;
2023

24+
/** The picker's "no filter" value. Not a `NewsSource`, so it cannot collide. */
25+
const ALL = "all";
26+
27+
const SOURCE_KEY = "news.source";
28+
2129
export function News() {
2230
const { t } = useSettings();
2331
const [visible, setVisible] = useState(PAGE);
32+
const [sources, setSources] = useState<NewsSourceInfo[]>([]);
33+
const [saved, setSaved] = usePersistedString(SOURCE_KEY);
2434
const {
2535
data: state,
2636
isValidating,
@@ -36,8 +46,30 @@ export function News() {
3646
[mutate],
3747
);
3848

49+
useEffect(() => {
50+
api
51+
.newsSources()
52+
.then(setSources)
53+
.catch(() => setSources([]));
54+
}, []);
55+
3956
const loading = isValidating;
4057

58+
// A source saved before it was renamed or dropped must not leave the list
59+
// permanently empty, so an unknown key reads as no filter at all. Until the
60+
// catalogue arrives nothing is known yet, and the saved key is trusted —
61+
// dropping it for that moment would flash the unfiltered list.
62+
const known = sources.length === 0 || sources.some((source) => source.id === saved);
63+
const selected = saved && known ? saved : ALL;
64+
65+
const pick = useCallback(
66+
(next: string) => {
67+
setSaved(next === ALL ? null : next);
68+
setVisible(PAGE);
69+
},
70+
[setSaved],
71+
);
72+
4173
const refreshButton = useMemo(
4274
() => (
4375
<button
@@ -56,41 +88,82 @@ export function News() {
5688
useHeaderSlot(refreshButton);
5789

5890
const digest = loadedValue(state);
59-
const items = digest?.items.slice(0, visible) ?? [];
91+
const filtered =
92+
selected === ALL
93+
? (digest?.items ?? [])
94+
: (digest?.items ?? []).filter((item) => item.source === selected);
95+
const items = filtered.slice(0, visible);
6096
const banner = loadBanner(state, fetchedAtLabel(digest?.freshness));
6197
const freshness = digest ? fetchedAtLabel(digest.freshness) : null;
6298

99+
// Filtered to one source, the row's own label repeats the picker.
100+
const showSource = selected === ALL;
101+
102+
// Filtered, the only failure worth reporting is the chosen source's own —
103+
// otherwise an empty list is explained by papers the reader is not reading.
104+
const selectedName = sources.find((source) => source.id === selected)?.name;
105+
const failed = (digest?.failedSources ?? []).filter(
106+
(name) => selected === ALL || name === selectedName,
107+
);
108+
109+
const emptyMessage = selected === ALL ? t("state.not-yet") : t("news.none-from-source");
110+
63111
return (
64112
<StateBanner state={banner} onRetry={() => load(true)}>
113+
<div className="mb-2">
114+
<Select
115+
ariaLabel={t("news.source")}
116+
value={selected}
117+
onChange={pick}
118+
options={[{ id: ALL, label: t("news.all-sources") }]}
119+
groups={[
120+
{
121+
label: t("news.group-nepali"),
122+
options: sources
123+
.filter((source) => !source.english)
124+
.map((source) => ({ id: source.id as string, label: source.name })),
125+
},
126+
{
127+
label: t("news.group-english"),
128+
options: sources
129+
.filter((source) => source.english)
130+
.map((source) => ({ id: source.id as string, label: source.name })),
131+
},
132+
]}
133+
/>
134+
</div>
135+
65136
{items.length === 0 && !loading ? (
66137
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
67138
<Icon name="news" className="size-8 text-text-muted" />
68-
<p className="text-text-secondary">{t("state.not-yet")}</p>
139+
<p className="text-text-secondary">{emptyMessage}</p>
69140
</div>
70141
) : (
71142
<Stagger className="space-y-1">
72143
{items.map((item) => (
73144
<FadeUp key={`${item.source}-${item.link}`}>
74-
<HeadlineRow item={item} onOpen={() => openExternalLink(item.link)} />
145+
<HeadlineRow
146+
item={item}
147+
showSource={showSource}
148+
onOpen={() => openExternalLink(item.link)}
149+
/>
75150
</FadeUp>
76151
))}
77152
</Stagger>
78153
)}
79154

80-
{digest && visible < digest.items.length && (
155+
{visible < filtered.length && (
81156
<button
82157
type="button"
83158
onClick={() => setVisible((count) => count + PAGE)}
84159
className="mt-2 w-full py-1.5 text-center text-[11px] text-text-muted hover:text-text-secondary"
85160
>
86-
{digest.items.length - visible} more
161+
{filtered.length - visible} more
87162
</button>
88163
)}
89164

90-
{digest && digest.failedSources.length > 0 && (
91-
<p className="mt-2 text-[10px] text-text-muted">
92-
Could not reach {digest.failedSources.join(", ")}.
93-
</p>
165+
{failed.length > 0 && (
166+
<p className="mt-2 text-[10px] text-text-muted">Could not reach {failed.join(", ")}.</p>
94167
)}
95168

96169
{freshness && items.length > 0 && (

apps/desktop/src/i18n/en.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,10 @@
5858
"bazar.watchlist": "Watchlist",
5959
"bazar.wholesale-note": "Wholesale rates at Kalimati. Shop prices are usually higher.",
6060
"bazar.worth": "Worth",
61-
"dashboard.gold": "Fine Gold · per tola",
62-
"dashboard.gold-no-change": "No change today",
6361
"dashboard.high": "H",
6462
"dashboard.low": "L",
65-
"dashboard.today": "today",
63+
"dashboard.nepse": "NEPSE index",
64+
"dashboard.nepse-no-change": "Unchanged today",
6665
"dashboard.update-available": "Update available",
6766
"dashboard.update-installed": "Update installed",
6867
"dashboard.update-install": "Install",
@@ -237,6 +236,11 @@
237236
"screen.date-converter": "Date Converter",
238237
"screen.date-details": "Date Details",
239238
"screen.exchange-rates": "Exchange Rates",
239+
"news.source": "Source",
240+
"news.all-sources": "All sources",
241+
"news.group-nepali": "Nepali",
242+
"news.group-english": "English",
243+
"news.none-from-source": "No headlines from this source right now.",
240244
"screen.news": "News",
241245
"screen.radio": "Radio",
242246
"screen.rashifal": "Rashifal",

apps/desktop/src/i18n/ne.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,10 @@
5858
"bazar.watchlist": "वाचलिस्ट",
5959
"bazar.wholesale-note": "कालीमाटीको थोक मूल्य। पसलको मूल्य प्रायः बढी हुन्छ।",
6060
"bazar.worth": "मूल्य",
61-
"dashboard.gold": "छापावाल सुन · तोला",
62-
"dashboard.gold-no-change": "आज परिवर्तन छैन",
6361
"dashboard.high": "अधिकतम",
6462
"dashboard.low": "न्यूनतम",
65-
"dashboard.today": "आज",
63+
"dashboard.nepse": "नेप्से सूचकाङ्क",
64+
"dashboard.nepse-no-change": "आज परिवर्तन छैन",
6665
"dashboard.update-available": "अद्यावधिक उपलब्ध छ",
6766
"dashboard.update-installed": "अद्यावधिक स्थापना भयो",
6867
"dashboard.update-install": "स्थापना",
@@ -237,6 +236,11 @@
237236
"screen.date-converter": "मिति रूपान्तरण",
238237
"screen.date-details": "मिति विवरण",
239238
"screen.exchange-rates": "विनिमय दर",
239+
"news.source": "स्रोत",
240+
"news.all-sources": "सबै स्रोत",
241+
"news.group-nepali": "नेपाली",
242+
"news.group-english": "अंग्रेजी",
243+
"news.none-from-source": "यो स्रोतबाट अहिले कुनै समाचार छैन।",
240244
"screen.news": "समाचार",
241245
"screen.radio": "रेडियो",
242246
"screen.rashifal": "राशिफल",

apps/desktop/src/index.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ html[data-theme="light"][data-window-material="opaque"] .app-window::before {
645645
pointer-events: none;
646646
}
647647

648-
.glance-metal::before {
648+
.glance-market::before {
649649
background: linear-gradient(
650650
145deg,
651651
color-mix(in srgb, var(--color-accent-mark) 20%, transparent) 0%,

0 commit comments

Comments
 (0)