Compare commits
3 commits
4f141b01c3
...
0baf51646e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0baf51646e | |||
| 4c710dcd20 | |||
| c12975926d |
7 changed files with 112 additions and 60 deletions
|
|
@ -2,44 +2,82 @@ import { test, expect } from '@playwright/test';
|
||||||
import { login, verifyCreateUser, todate } from './util';
|
import { login, verifyCreateUser, todate } from './util';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can log out', async ({ page }) => {
|
test('can log out', async ({ page }) => {
|
||||||
await page.getByText("Logout").click();
|
await page.getByText("Logout").click();
|
||||||
await expect(page.getByLabel("Username")).toBeVisible();
|
await expect(page.getByLabel("Username")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has no contacts', async ({ page }) => {
|
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 }) => {
|
test('can add contacts', async ({ page }) => {
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
||||||
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
|
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "never" for unfreshened contacts', async ({ page }) => {
|
test('shows "never" for unfreshened contacts', async ({ page }) => {
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
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 }) => {
|
test('shows the date for fresh contacts', async ({ page }) => {
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('button', { name: /fresh/i }).click();
|
await page.getByRole('button', { name: /fresh/i }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`);
|
await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar is sorted alphabetically', async ({ page }) => {
|
test('sidebar is sorted alphabetically', async ({ page }) => {
|
||||||
await verifyCreateUser(page, { names: ['Zulu'] });
|
await verifyCreateUser(page, { names: ['Zulu'] });
|
||||||
await verifyCreateUser(page, { names: ['Alfa'] });
|
await verifyCreateUser(page, { names: ['Alfa'] });
|
||||||
await verifyCreateUser(page, { names: ['Golf'] });
|
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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
insert into contacts(id, birthday, manually_freshened_at) values
|
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
|
insert into names(contact_id, sort, name) values
|
||||||
(0, 0, 'Alex Aaronson'),
|
(0, 0, 'Alex Aaronson'),
|
||||||
(0, 1, 'Alexi'),
|
(0, 1, 'Alexi'),
|
||||||
|
|
@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values
|
||||||
(1, 'ABC', 'abc');
|
(1, 'ABC', 'abc');
|
||||||
|
|
||||||
insert into contacts(id, birthday) values
|
insert into contacts(id, birthday) values
|
||||||
(2, '19951018');
|
(2, '1995-10-18');
|
||||||
insert into names(contact_id, sort, name) values
|
insert into names(contact_id, sort, name) values
|
||||||
(2, 0, 'Charlie Certaindate');
|
(2, 0, 'Charlie Certaindate');
|
||||||
insert into groups(contact_id, name, slug) values
|
insert into groups(contact_id, name, slug) values
|
||||||
|
|
|
||||||
7
migrations/each_user/0014_birthday-format.sql
Normal file
7
migrations/each_user/0014_birthday-format.sql
Normal file
|
|
@ -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]';
|
||||||
|
|
@ -20,11 +20,10 @@ pub enum Birthday {
|
||||||
|
|
||||||
impl Display for Birthday {
|
impl Display for Birthday {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
let str = match self {
|
match self {
|
||||||
Birthday::Date(date) => date.to_string(),
|
Birthday::Date(date) => write!(f, "{}", date),
|
||||||
Birthday::Text(t) => t.value.clone(),
|
Birthday::Text(t) => write!(f, "{}", t.value),
|
||||||
};
|
}
|
||||||
write!(f, "{}", str)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 {
|
impl FromStr for Birthday {
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,6 @@ impl YearOptionalDate {
|
||||||
None
|
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 {
|
impl Display for YearOptionalDate {
|
||||||
|
|
@ -66,21 +57,18 @@ impl Display for YearOptionalDate {
|
||||||
impl FromStr for YearOptionalDate {
|
impl FromStr for YearOptionalDate {
|
||||||
type Err = anyhow::Error;
|
type Err = anyhow::Error;
|
||||||
fn from_str(str: &str) -> Result<Self, Self::Err> {
|
fn from_str(str: &str) -> Result<Self, Self::Err> {
|
||||||
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) {
|
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 month = i8::from_str(&caps[2]).unwrap();
|
||||||
let day = i8::from_str(&caps[3]).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 });
|
return Ok(Self { year, month, day });
|
||||||
}
|
}
|
||||||
Err(anyhow::Error::msg(format!(
|
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
|
str
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +98,6 @@ where
|
||||||
&self,
|
&self,
|
||||||
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
|
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
|
||||||
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
|
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
|
<String as Encode<'r, Sqlite>>::encode(self.to_string(), buf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,10 +278,10 @@ mod get {
|
||||||
input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity));
|
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" } ")" }
|
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 {
|
div {
|
||||||
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
|
input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}")));
|
||||||
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
|
span .hint { code { "(yyyy-)?mm-dd" } " or free text" }
|
||||||
}
|
}
|
||||||
label for="manually_freshened_on" { "freshened" }
|
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])" {
|
div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use cache_bust::asset;
|
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 maud::{Markup, html};
|
||||||
use sqlx::sqlite::SqlitePool;
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
|
@ -53,13 +53,40 @@ fn birthdays_section(
|
||||||
prev_birthdays: &Vec<KnownBirthdayContact>,
|
prev_birthdays: &Vec<KnownBirthdayContact>,
|
||||||
upcoming_birthdays: &Vec<KnownBirthdayContact>,
|
upcoming_birthdays: &Vec<KnownBirthdayContact>,
|
||||||
) -> Result<Markup, AppError> {
|
) -> Result<Markup, AppError> {
|
||||||
|
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! {
|
Ok(html! {
|
||||||
div id="birthdays" {
|
div id="birthdays" {
|
||||||
h2 { "Birthdays" }
|
h2 { "Birthdays" }
|
||||||
#birthday-sections {
|
#birthday-sections {
|
||||||
.datelist {
|
.datelist #upcoming {
|
||||||
h3 { "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)) {
|
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||||
(contact.display)
|
(contact.display)
|
||||||
}
|
}
|
||||||
|
|
@ -68,9 +95,9 @@ fn birthdays_section(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.datelist {
|
.datelist #recent {
|
||||||
h3 { "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)) {
|
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||||
(contact.display)
|
(contact.display)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue