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() {