From c12975926d52595f8e46d9a1c6d00097a30557d1 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 12:05:13 -0500 Subject: [PATCH 1/3] spec: show birthdays until a week out --- e2e/pages/home.spec.ts | 78 ++++++++++++++++++++++++-------- src/models/birthday.rs | 16 ++----- src/models/year_optional_date.rs | 11 +---- src/web/home.rs | 4 +- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 13b9fa1..8ec39dd 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -2,44 +2,82 @@ import { test, expect } from '@playwright/test'; import { login, verifyCreateUser, todate } from './util'; test.beforeEach(async ({ page }) => { - await login(page); + await login(page); }); test('can log out', async ({ page }) => { - await page.getByText("Logout").click(); - await expect(page.getByLabel("Username")).toBeVisible(); + await page.getByText("Logout").click(); + await expect(page.getByLabel("Username")).toBeVisible(); }); test('has no contacts', async ({ page }) => { - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); + await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); }); test('can add contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await verifyCreateUser(page, { names: ['Jack Contact'] }); - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); + await verifyCreateUser(page, { names: ['John Contact'] }); + await verifyCreateUser(page, { names: ['Jack Contact'] }); + await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); }); test('shows "never" for unfreshened contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: 'Mascarpone' }).click(); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect(page.locator('#freshness')).toContainText('John Contactnever'); + await expect(page.locator('#freshness')).toContainText('John Contactnever'); }); test('shows the date for fresh contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('button', { name: /fresh/i }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('button', { name: /fresh/i }).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`); }); test('sidebar is sorted alphabetically', async ({ page }) => { - await verifyCreateUser(page, { names: ['Zulu'] }); - await verifyCreateUser(page, { names: ['Alfa'] }); - await verifyCreateUser(page, { names: ['Golf'] }); + await verifyCreateUser(page, { names: ['Zulu'] }); + await verifyCreateUser(page, { names: ['Alfa'] }); + await verifyCreateUser(page, { names: ['Golf'] }); - await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); + await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); +}); + +test('upcoming and recent show at least one birthday a week away', async ({ page }) => { + const monthday = d => d.toISOString().split("T")[0].replace(/^\d{4}-/, ''); + const today = monthday(new Date()); + const yesterday = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() - 1); + return date; + })()); + const tomorrow = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() + 1); + return date; + })()); + const aMonthAgo = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() - 28); + return date; + })()); + const inAMonth = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() + 28); + return date; + })()); + await verifyCreateUser(page, { names: ['Alfa'], birthday: today }); + await verifyCreateUser(page, { names: ['Beta'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Echo'], birthday: today }); + await verifyCreateUser(page, { names: ['Golf'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Lima'], birthday: tomorrow }); + await verifyCreateUser(page, { names: ['Mike'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Xray'], birthday: inAMonth }); + await verifyCreateUser(page, { names: ['Zulu'], birthday: aMonthAgo }); + + await page.goto('/'); + + await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4); + await expect(page.locator('#recent').getByRole('link')).toHaveCount(4); }); diff --git a/src/models/birthday.rs b/src/models/birthday.rs index f7c0256..2db0296 100644 --- a/src/models/birthday.rs +++ b/src/models/birthday.rs @@ -20,11 +20,10 @@ pub enum Birthday { impl Display for Birthday { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let str = match self { - Birthday::Date(date) => date.to_string(), - Birthday::Text(t) => t.value.clone(), - }; - write!(f, "{}", str) + match self { + Birthday::Date(date) => write!(f, "{}", date), + Birthday::Text(t) => write!(f, "{}", t.value), + } } } @@ -56,13 +55,6 @@ impl Birthday { } } } - - pub fn serialize(&self) -> String { - match &self { - Birthday::Text(text) => text.value.clone(), - Birthday::Date(date) => date.serialize(), - } - } } impl FromStr for Birthday { diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index fa1fc1f..d0052b8 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -43,15 +43,6 @@ impl YearOptionalDate { None } } - - pub fn serialize(&self) -> String { - format!( - "{}{:0>2}{:0>2}", - self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)), - self.month, - self.day - ) - } } impl Display for YearOptionalDate { @@ -110,6 +101,6 @@ where &self, buf: &mut ::ArgumentBuffer<'r>, ) -> Result> { - >::encode(self.serialize(), buf) + >::encode(self.to_string(), buf) } } diff --git a/src/web/home.rs b/src/web/home.rs index 231ee6a..81ba00e 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -57,7 +57,7 @@ fn birthdays_section( div id="birthdays" { h2 { "Birthdays" } #birthday-sections { - .datelist { + .datelist #upcoming { h3 { "upcoming" } @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { @@ -68,7 +68,7 @@ fn birthdays_section( } } } - .datelist { + .datelist #recent { h3 { "recent" } @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { From 4c710dcd201dcf37e089020f1e98c2b214a6f9c6 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 13:20:00 -0500 Subject: [PATCH 2/3] feat: sane date format --- migrations/demo.sql | 4 ++-- migrations/each_user/0014_birthday-format.sql | 7 +++++++ src/models/year_optional_date.rs | 13 +++++-------- src/web/contact/mod.rs | 6 +++--- 4 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 migrations/each_user/0014_birthday-format.sql diff --git a/migrations/demo.sql b/migrations/demo.sql index 8eb0d76..73b2d4b 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -1,5 +1,5 @@ insert into contacts(id, birthday, manually_freshened_at) values - (0, '--0415', '2000-01-01T12:00:00'); + (0, '04-15', '2000-01-01T12:00:00'); insert into names(contact_id, sort, name) values (0, 0, 'Alex Aaronson'), (0, 1, 'Alexi'), @@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values (1, 'ABC', 'abc'); insert into contacts(id, birthday) values - (2, '19951018'); + (2, '1995-10-18'); insert into names(contact_id, sort, name) values (2, 0, 'Charlie Certaindate'); insert into groups(contact_id, name, slug) values diff --git a/migrations/each_user/0014_birthday-format.sql b/migrations/each_user/0014_birthday-format.sql new file mode 100644 index 0000000..80b69cf --- /dev/null +++ b/migrations/each_user/0014_birthday-format.sql @@ -0,0 +1,7 @@ +update contacts + set birthday = substr(birthday, 3, 2) || '-' || substr(birthday, 5, 2) + where birthday GLOB '--[01][0-9][0-3][0-9]'; + +update contacts + set birthday = substr(birthday, 1, 4) || '-' || substr(birthday, 5, 2) || '-' || substr(birthday, 7, 2) + where birthday GLOB '[0-9][0-9][0-9][0-9][01][0-9][0-3][0-9]'; diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index d0052b8..a4cd381 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -57,21 +57,18 @@ impl Display for YearOptionalDate { impl FromStr for YearOptionalDate { type Err = anyhow::Error; fn from_str(str: &str) -> Result { - let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap(); + let date_re = Regex::new(r"^(?:([0-9]{4})-)?([0-9]{2})-([0-9]{2})$").unwrap(); if let Some(caps) = date_re.captures(str) { - let year_str = &caps[1]; + let year = caps + .get(1) + .map(|yyyy| i16::from_str(yyyy.as_str()).unwrap()); let month = i8::from_str(&caps[2]).unwrap(); let day = i8::from_str(&caps[3]).unwrap(); - let year = if year_str == "--" { - None - } else { - Some(i16::from_str(year_str).unwrap()) - }; return Ok(Self { year, month, day }); } Err(anyhow::Error::msg(format!( - "parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/", + "parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}-)?[0-9]{{2}}-[0-9]{{2}}/", str ))) } diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 7dc487b..b99abdb 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -278,10 +278,10 @@ mod get { input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity)); span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" } } - label { "birthday" } + label for="birthday" { "birthday" } div { - input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize())); - span .hint { code { "(yyyy|--)mmdd" } " or free text" } + input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}"))); + span .hint { code { "(yyyy-)?mm-dd" } " or free text" } } label for="manually_freshened_on" { "freshened" } div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" { From 0baf51646ed361eed03b6e4893ad5600e2dbcb43 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 13:20:22 -0500 Subject: [PATCH 3/3] feat: show birthdays until a week out --- src/web/home.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/web/home.rs b/src/web/home.rs index 81ba00e..49fbb4a 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -1,7 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use cache_bust::asset; -use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone}; +use jiff::{Timestamp, ToSpan, Unit, Zoned, civil, tz::TimeZone}; use maud::{Markup, html}; use sqlx::sqlite::SqlitePool; @@ -53,13 +53,40 @@ fn birthdays_section( prev_birthdays: &Vec, upcoming_birthdays: &Vec, ) -> Result { + let now = Timestamp::now().to_zoned(TimeZone::UTC); + let in_a_week = upcoming_birthdays + .iter() + .position(|b| { + now.until(&b.next_birthday.to_zoned(TimeZone::UTC).unwrap()) + .unwrap() + .compare((&1_i32.week(), &now)) + .unwrap() + != std::cmp::Ordering::Less + }) + .unwrap_or(upcoming_birthdays.len()); + let upcoming = &upcoming_birthdays + [0..std::cmp::min(std::cmp::max(3, in_a_week + 1), upcoming_birthdays.len())]; + + let a_week_ago = prev_birthdays + .iter() + .position(|b| { + now.since(&b.prev_birthday.to_zoned(TimeZone::UTC).unwrap()) + .unwrap() + .compare((&1_i32.week(), &now)) + .unwrap() + != std::cmp::Ordering::Less + }) + .unwrap_or(upcoming_birthdays.len()); + let recent = + &prev_birthdays[0..std::cmp::min(std::cmp::max(3, a_week_ago + 1), prev_birthdays.len())]; + Ok(html! { div id="birthdays" { h2 { "Birthdays" } #birthday-sections { .datelist #upcoming { h3 { "upcoming" } - @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { + @for contact in upcoming { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } @@ -70,7 +97,7 @@ fn birthdays_section( } .datelist #recent { h3 { "recent" } - @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { + @for contact in recent { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) }