From b851e0257ccfff4a3a08f2df82270b7ecf4d0814 Mon Sep 17 00:00:00 2001 From: mace Date: Tue, 9 Jun 2026 19:50:47 +0200 Subject: [PATCH] fix sync issue, frontend improvement --- src/reader/sync.rs | 98 ++++++++++++++++++--- vue/src/assets/main.css | 6 +- vue/src/components/AppNav.vue | 9 +- vue/src/components/RssFeeds.vue | 48 +++++++++- vue/src/components/__tests__/AppNav.spec.js | 20 +++++ vue/src/composables/useFeeds.js | 3 +- 6 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/reader/sync.rs b/src/reader/sync.rs index 5b45560..658d1ff 100644 --- a/src/reader/sync.rs +++ b/src/reader/sync.rs @@ -64,6 +64,27 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { let item_title = item.title.clone().unwrap(); log::info!("Create feed item: {}", item_title); + // Resolve the publication date before any HTML parsing or DB work so we can + // bail out early for old articles. Items without a pub_date are treated as + // current (inserted unconditionally) — feeds that don't publish dates are + // typically small/curated enough that this is fine. + let mut time: NaiveDateTime = Local::now().naive_local(); + if let Some(pub_date) = item.pub_date() { + time = match get_date(pub_date) { + Ok(date) => date, + Err(err) => { + log::error!("could not parse pub date: {}", err); + time + } + }; + } + + let cutoff = Local::now().naive_local() - Duration::days(14); + if time < cutoff { + log::info!("Skipping item {} (older than 2 weeks).", item_title); + return; + } + let base_content: &str = item.content().or(item.description()).unwrap_or_default(); let frag = Html::parse_fragment(base_content); @@ -96,17 +117,6 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { .unwrap(); if existing_item.is_empty() { - log::info!("{:?}", item.pub_date()); - let mut time: NaiveDateTime = Local::now().naive_local(); - if item.pub_date().is_some() { - time = match get_date(item.pub_date().unwrap()) { - Ok(date) => date, - Err(err) => { - log::error!("could not unwrap pub date: {}", err); - time - } - }; - } let new_feed_item = NewFeedItem::new( feed.id, content.clone(), @@ -331,6 +341,72 @@ mod tests { .ok(); } + #[actix_web::test] + async fn create_feed_item_skips_articles_older_than_two_weeks() { + let mut connection = establish_connection(); + let suffix = unique_suffix(); + + let new_user = NewUser::new( + format!("age_skip_test_{suffix}"), + format!("age_skip_{suffix}@example.test"), + "secret".to_string(), + ); + let user: User = diesel::insert_into(users::table) + .values(&new_user) + .get_result(&mut connection) + .unwrap(); + + let new_feed = NewFeed::new( + format!("Age skip test feed {suffix}"), + format!("https://example.test/feed/{suffix}"), + user.id, + ); + let feed: Feed = diesel::insert_into(feed::table) + .values(&new_feed) + .get_result(&mut connection) + .unwrap(); + + // Item with a pub_date 20 days ago — should be ignored by create_feed_item. + let old_date = (Local::now() - Duration::days(20)) + .format("%a, %d %b %Y %H:%M:%S %z") + .to_string(); + let mut old_item = Item::default(); + old_item.set_title(Some(format!("Old article {suffix}"))); + old_item.set_link(Some(format!("https://example.test/old/{suffix}"))); + old_item.set_pub_date(Some(old_date)); + old_item.set_content(Some("

old

".to_string())); + + // Item without a pub_date — treated as current, should be inserted. + let mut fresh_item = Item::default(); + fresh_item.set_title(Some(format!("Fresh article {suffix}"))); + fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}"))); + fresh_item.set_content(Some("

fresh

".to_string())); + + create_feed_item(old_item, &feed, &mut connection); + create_feed_item(fresh_item, &feed, &mut connection); + + let items: Vec = feed_item::table + .filter(feed_id.eq(feed.id)) + .load(&mut connection) + .unwrap(); + + assert_eq!(1, items.len(), "old item should have been skipped"); + assert!( + items[0].title.contains("Fresh article"), + "only the fresh item should be present" + ); + + diesel::delete(feed_item::table.filter(feed_id.eq(feed.id))) + .execute(&mut connection) + .ok(); + diesel::delete(feed::table.filter(feed::id.eq(feed.id))) + .execute(&mut connection) + .ok(); + diesel::delete(users::table.filter(users::id.eq(user.id))) + .execute(&mut connection) + .ok(); + } + #[actix_web::test] async fn create_feed_item_does_not_duplicate_existing_items() { let mut connection = establish_connection(); diff --git a/vue/src/assets/main.css b/vue/src/assets/main.css index 46b8640..670452f 100644 --- a/vue/src/assets/main.css +++ b/vue/src/assets/main.css @@ -3,7 +3,7 @@ #app { max-width: 1280px; margin: 0 auto; - padding: 1rem; + padding: 0.5rem; font-weight: normal; } @@ -69,7 +69,7 @@ a, .feed-title { cursor: pointer; font-family: 'Courier New'; - font-size: clamp(1.1rem, 4vw, 1.4rem); + font-size: clamp(1.25rem, 4.5vw, 1.6rem); font-weight: bold; color: var(--color-accent-2); border-bottom: 1px solid #ccc; @@ -110,6 +110,6 @@ h3 { @media (min-width: 768px) { #app { - padding: 2rem; + padding: 0.75rem; } } diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue index 30034a2..3acf979 100644 --- a/vue/src/components/AppNav.vue +++ b/vue/src/components/AppNav.vue @@ -4,7 +4,7 @@ import { RouterLink, useRouter } from 'vue-router' import { useFeeds } from '@/composables/useFeeds' const router = useRouter() -const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead } = useFeeds() +const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds() const menuOpen = ref(false) @@ -52,7 +52,7 @@ function handleToggleLayout() { @@ -123,6 +139,13 @@ onMounted(async () => { width: auto; } +.feed-original-link { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + .feed-original-link a { display: inline-flex; align-items: center; @@ -136,6 +159,25 @@ onMounted(async () => { text-decoration: underline; } +.feed-share-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + padding: 0; + border: none; + background: transparent; + color: var(--color-text); + opacity: 0.45; + cursor: pointer; + transition: opacity 0.15s; +} + +.feed-share-btn:hover { + opacity: 1; +} + .article-single { position: relative; padding-bottom: 5rem; @@ -179,10 +221,14 @@ onMounted(async () => { line-height: 1; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s, border-color 0.15s; } -.article-nav__btn:hover:not(:disabled) { +.article-nav__btn:hover:not(:disabled), +.article-nav__btn:focus-visible { border-color: var(--color-border-hover); + opacity: 1; } .article-nav__btn:disabled { diff --git a/vue/src/components/__tests__/AppNav.spec.js b/vue/src/components/__tests__/AppNav.spec.js index 642357d..db86cf4 100644 --- a/vue/src/components/__tests__/AppNav.spec.js +++ b/vue/src/components/__tests__/AppNav.spec.js @@ -148,6 +148,26 @@ describe('AppNav', () => { confirmSpy.mockRestore() }) + it('shows the unread count in the title when there are articles', async () => { + const { feeds } = useFeeds() + feeds.value = [ + { id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' }, + { id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' }, + ] + + const wrapper = mount(AppNav, { global: { plugins: [router] } }) + await flushPromises() + + expect(wrapper.find('.app-nav__title').text()).toContain('(2)') + }) + + it('hides the unread count when there are no articles', async () => { + const wrapper = mount(AppNav, { global: { plugins: [router] } }) + await flushPromises() + + expect(wrapper.find('.app-nav__unread').exists()).toBe(false) + }) + it('does not mark articles as read when the confirmation is dismissed', async () => { const { feeds } = useFeeds() feeds.value = [ diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index 4ef08d3..8f60a05 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -10,7 +10,7 @@ const message = ref('') const showModal = ref(false) const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const currentIndex = ref(0) -const layout = ref('list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu +const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu let observer; // Declare observer outside the setup function let initialLoad = false @@ -219,6 +219,7 @@ function toggleViewMode() { function toggleLayout() { layout.value = layout.value === 'list' ? 'cards' : 'list' + localStorage.setItem('layout', layout.value) } function nextArticle() {