refactor: extract duplicated helpers for tasting notes, used_percent, format_datetime, and map builder
- Add parse_and_categorize() in tasting_notes.rs, replacing identical
split-trim-categorize pipelines in mod.rs, roasts.rs, and timeline.rs
- Add used_percent() in bags.rs, deduplicating the calculation in
BagView::from_domain and BagDetailView::from_parts
- Add format_datetime() in views/mod.rs, replacing 16 paired occurrences
of .format("%Y-%m-%d") / .format("%H:%M") across 7 view files
- Add build_origin_roaster_map() in views/mod.rs, replacing identical
map-entry building blocks in bags.rs, brews.rs, and roasts.rs detail views
This commit is contained in:
parent
3d49236c13
commit
766ac1432d
10 changed files with 125 additions and 132 deletions
|
|
@ -4,7 +4,17 @@ use crate::domain::roasters::Roaster;
|
|||
use crate::domain::roasts::Roast;
|
||||
|
||||
use super::tasting_notes::TastingNoteView;
|
||||
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info};
|
||||
use super::{
|
||||
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
|
||||
};
|
||||
|
||||
fn used_percent(amount: f64, remaining: f64) -> u8 {
|
||||
if amount > 0.0 {
|
||||
(((amount - remaining) / amount) * 100.0).clamp(0.0, 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BagView {
|
||||
|
|
@ -26,12 +36,8 @@ pub struct BagView {
|
|||
|
||||
impl BagView {
|
||||
pub fn from_domain(bag: BagWithRoast) -> Self {
|
||||
let used_percent = if bag.bag.amount > 0.0 {
|
||||
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0)
|
||||
as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let used_percent = used_percent(bag.bag.amount, bag.bag.remaining);
|
||||
let (created_date, created_time) = format_datetime(bag.bag.created_at);
|
||||
Self {
|
||||
id: bag.bag.id.to_string(),
|
||||
roast_id: bag.bag.roast_id.to_string(),
|
||||
|
|
@ -43,8 +49,8 @@ impl BagView {
|
|||
.bag
|
||||
.finished_at
|
||||
.map_or_else(|| "—".to_string(), |d| d.format("%Y-%m-%d").to_string()),
|
||||
created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: bag.bag.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
roast_name: bag.roast_name,
|
||||
roaster_name: bag.roaster_name,
|
||||
roast_slug: bag.roast_slug,
|
||||
|
|
@ -103,14 +109,9 @@ impl BagDetailView {
|
|||
let coffee = build_coffee_info(roast);
|
||||
let roaster_info = build_roaster_info(roaster);
|
||||
|
||||
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||
if let Some(ref o) = roast.origin
|
||||
&& !o.is_empty()
|
||||
{
|
||||
map_entries.push((o.as_str(), 2));
|
||||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
let (map_countries, map_max, legend_entries) =
|
||||
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
|
||||
let (created_date, created_time) = format_datetime(bag.bag.created_at);
|
||||
|
||||
Self {
|
||||
id: bag.bag.id.to_string(),
|
||||
|
|
@ -130,12 +131,7 @@ impl BagDetailView {
|
|||
roaster_homepage: roaster_info.homepage,
|
||||
amount: format_weight(bag.bag.amount),
|
||||
remaining: format_weight(bag.bag.remaining),
|
||||
used_percent: if bag.bag.amount > 0.0 {
|
||||
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0)
|
||||
as u8
|
||||
} else {
|
||||
0
|
||||
},
|
||||
used_percent: used_percent(bag.bag.amount, bag.bag.remaining),
|
||||
closed: bag.bag.closed,
|
||||
roast_date: bag.bag.roast_date.map(|d| d.to_string()),
|
||||
finished_date: bag
|
||||
|
|
@ -144,18 +140,9 @@ impl BagDetailView {
|
|||
.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
map_countries,
|
||||
map_max,
|
||||
legend_entries: vec![
|
||||
LegendEntry {
|
||||
label: "Origin",
|
||||
opacity: "",
|
||||
},
|
||||
LegendEntry {
|
||||
label: "Roaster",
|
||||
opacity: "opacity-50",
|
||||
},
|
||||
],
|
||||
created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: bag.bag.created_at.format("%H:%M").to_string(),
|
||||
legend_entries,
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ use crate::domain::roasters::Roaster;
|
|||
use crate::domain::roasts::Roast;
|
||||
|
||||
use super::tasting_notes::TastingNoteView;
|
||||
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info, relative_date};
|
||||
use super::{
|
||||
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
|
||||
relative_date,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QuickNoteView {
|
||||
|
|
@ -92,6 +95,7 @@ impl BrewView {
|
|||
.map(|n| n.form_value.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let (created_date, created_time) = format_datetime(brew.brew.created_at);
|
||||
|
||||
Self {
|
||||
id: brew.brew.id.to_string(),
|
||||
|
|
@ -119,8 +123,8 @@ impl BrewView {
|
|||
quick_notes,
|
||||
quick_notes_label,
|
||||
quick_notes_raw,
|
||||
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: brew.brew.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
relative_date_label: relative_date(brew.brew.created_at),
|
||||
coffee_weight_raw: brew.brew.coffee_weight,
|
||||
grind_setting_raw: brew.brew.grind_setting,
|
||||
|
|
@ -236,14 +240,9 @@ impl BrewDetailView {
|
|||
let coffee = build_coffee_info(roast);
|
||||
let roaster_info = build_roaster_info(roaster);
|
||||
|
||||
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||
if let Some(ref o) = roast.origin
|
||||
&& !o.is_empty()
|
||||
{
|
||||
map_entries.push((o.as_str(), 2));
|
||||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
let (map_countries, map_max, legend_entries) =
|
||||
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
|
||||
let (created_date, created_time) = format_datetime(brew.brew.created_at);
|
||||
|
||||
let quick_notes_label = brew
|
||||
.brew
|
||||
|
|
@ -280,18 +279,9 @@ impl BrewDetailView {
|
|||
filter_paper_name: brew.filter_paper_name,
|
||||
map_countries,
|
||||
map_max,
|
||||
legend_entries: vec![
|
||||
LegendEntry {
|
||||
label: "Origin",
|
||||
opacity: "",
|
||||
},
|
||||
LegendEntry {
|
||||
label: "Roaster",
|
||||
opacity: "opacity-50",
|
||||
},
|
||||
],
|
||||
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: brew.brew.created_at.format("%H:%M").to_string(),
|
||||
legend_entries,
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::domain::cafes::Cafe;
|
|||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
use crate::domain::nearby_cafes::NearbyCafeResult;
|
||||
|
||||
use super::{LegendEntry, build_map_data};
|
||||
use super::{LegendEntry, build_map_data, format_datetime};
|
||||
|
||||
pub struct CafeDetailView {
|
||||
pub id: String,
|
||||
|
|
@ -21,6 +21,7 @@ pub struct CafeDetailView {
|
|||
|
||||
impl CafeDetailView {
|
||||
pub fn from_domain(cafe: Cafe) -> Self {
|
||||
let (created_date, created_time) = format_datetime(cafe.created_at);
|
||||
let country_flag = country_to_iso(&cafe.country)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
|
@ -44,8 +45,8 @@ impl CafeDetailView {
|
|||
label: "Cafe",
|
||||
opacity: "",
|
||||
}],
|
||||
created_date: cafe.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: cafe.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,8 +93,7 @@ impl From<Cafe> for CafeView {
|
|||
.unwrap_or_default();
|
||||
|
||||
let created_at_sort_key = created_at.timestamp();
|
||||
let created_date = created_at.format("%Y-%m-%d").to_string();
|
||||
let created_time = created_at.format("%H:%M").to_string();
|
||||
let (created_date, created_time) = format_datetime(created_at);
|
||||
|
||||
Self {
|
||||
detail_path,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::domain::roasters::Roaster;
|
|||
use crate::domain::roasts::Roast;
|
||||
|
||||
use super::tasting_notes::TastingNoteView;
|
||||
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info};
|
||||
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info, format_datetime};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CupView {
|
||||
|
|
@ -23,6 +23,7 @@ pub struct CupView {
|
|||
|
||||
impl CupView {
|
||||
pub fn from_domain(cup: CupWithDetails) -> Self {
|
||||
let (created_date, created_time) = format_datetime(cup.cup.created_at);
|
||||
Self {
|
||||
id: cup.cup.id.to_string(),
|
||||
roast_name: cup.roast_name,
|
||||
|
|
@ -32,8 +33,8 @@ impl CupView {
|
|||
cafe_name: cup.cafe_name,
|
||||
cafe_slug: cup.cafe_slug,
|
||||
cafe_city: cup.cafe_city,
|
||||
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: cup.cup.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +93,7 @@ impl CupDetailView {
|
|||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
let (created_date, created_time) = format_datetime(cup.cup.created_at);
|
||||
|
||||
Self {
|
||||
id: cup.cup.id.to_string(),
|
||||
|
|
@ -135,8 +137,8 @@ impl CupDetailView {
|
|||
opacity: "opacity-35",
|
||||
},
|
||||
],
|
||||
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: cup.cup.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use crate::domain::gear::Gear;
|
||||
|
||||
use super::format_datetime;
|
||||
|
||||
pub struct GearDetailView {
|
||||
pub id: String,
|
||||
pub category_label: String,
|
||||
|
|
@ -11,13 +13,14 @@ pub struct GearDetailView {
|
|||
|
||||
impl GearDetailView {
|
||||
pub fn from_domain(gear: Gear) -> Self {
|
||||
let (created_date, created_time) = format_datetime(gear.created_at);
|
||||
Self {
|
||||
id: gear.id.to_string(),
|
||||
category_label: gear.category.display_label().to_string(),
|
||||
make: gear.make,
|
||||
model: gear.model,
|
||||
created_date: gear.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: gear.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +39,7 @@ pub struct GearView {
|
|||
|
||||
impl GearView {
|
||||
pub fn from_domain(gear: Gear) -> Self {
|
||||
let (created_date, created_time) = format_datetime(gear.created_at);
|
||||
Self {
|
||||
id: gear.id.to_string(),
|
||||
category: gear.category.as_str().to_string(),
|
||||
|
|
@ -43,8 +47,8 @@ impl GearView {
|
|||
make: gear.make.clone(),
|
||||
model: gear.model.clone(),
|
||||
full_name: format!("{} {}", gear.make, gear.model),
|
||||
created_date: gear.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: gear.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ fn relative_date(dt: DateTime<Utc>) -> String {
|
|||
crate::domain::formatting::format_relative_time(dt, Utc::now())
|
||||
}
|
||||
|
||||
pub(crate) fn format_datetime(dt: DateTime<Utc>) -> (String, String) {
|
||||
(
|
||||
dt.format("%Y-%m-%d").to_string(),
|
||||
dt.format("%H:%M").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct Paginated<T> {
|
||||
pub items: Vec<T>,
|
||||
pub page: u32,
|
||||
|
|
@ -388,17 +395,7 @@ pub(crate) fn build_coffee_info(roast: &crate::domain::roasts::Roast) -> CoffeeI
|
|||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let notes = roast
|
||||
.tasting_notes
|
||||
.iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|n| tasting_notes::categorize(&n))
|
||||
.collect();
|
||||
let notes = tasting_notes::parse_and_categorize(&roast.tasting_notes);
|
||||
|
||||
CoffeeInfo {
|
||||
origin: if origin.is_empty() {
|
||||
|
|
@ -450,6 +447,36 @@ pub(crate) fn build_roaster_info(roaster: &crate::domain::roasters::Roaster) ->
|
|||
}
|
||||
}
|
||||
|
||||
/// Build origin + roaster country map data with standard legend entries.
|
||||
///
|
||||
/// Origin gets weight 2, roaster country gets weight 1. Returns the
|
||||
/// `(data-countries, data-max, legend_entries)` tuple used by detail pages.
|
||||
pub(crate) fn build_origin_roaster_map(
|
||||
origin: Option<&str>,
|
||||
roaster_country: &str,
|
||||
) -> (String, u32, Vec<LegendEntry>) {
|
||||
let mut entries: Vec<(&str, u32)> = Vec::new();
|
||||
if let Some(o) = origin.filter(|o| !o.is_empty()) {
|
||||
entries.push((o, 2));
|
||||
}
|
||||
entries.push((roaster_country, 1));
|
||||
let (map_countries, map_max) = build_map_data(&entries);
|
||||
(
|
||||
map_countries,
|
||||
map_max,
|
||||
vec![
|
||||
LegendEntry {
|
||||
label: "Origin",
|
||||
opacity: "",
|
||||
},
|
||||
LegendEntry {
|
||||
label: "Roaster",
|
||||
opacity: "opacity-50",
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build `data-countries` and `data-max` values for the world-map component.
|
||||
///
|
||||
/// Accepts `(country_name, weight)` pairs where higher weights render darker.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
use crate::domain::roasters::Roaster;
|
||||
|
||||
use super::{LegendEntry, build_map_data};
|
||||
use super::{LegendEntry, build_map_data, format_datetime};
|
||||
|
||||
pub struct RoasterDetailView {
|
||||
pub id: String,
|
||||
|
|
@ -23,6 +23,7 @@ impl RoasterDetailView {
|
|||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
let (map_countries, map_max) = build_map_data(&[(&roaster.country, 1)]);
|
||||
let (created_date, created_time) = format_datetime(roaster.created_at);
|
||||
|
||||
Self {
|
||||
id: roaster.id.to_string(),
|
||||
|
|
@ -37,8 +38,8 @@ impl RoasterDetailView {
|
|||
label: "Roaster",
|
||||
opacity: "",
|
||||
}],
|
||||
created_date: roaster.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: roaster.created_at.format("%H:%M").to_string(),
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -101,8 +102,7 @@ impl From<Roaster> for RoasterView {
|
|||
.unwrap_or_default();
|
||||
|
||||
let created_at_sort_key = created_at.timestamp();
|
||||
let created_date = created_at.format("%Y-%m-%d").to_string();
|
||||
let created_time = created_at.format("%H:%M").to_string();
|
||||
let (created_date, created_time) = format_datetime(created_at);
|
||||
|
||||
Self {
|
||||
detail_path,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use crate::domain::roasters::Roaster;
|
|||
use crate::domain::roasts::{Roast, RoastWithRoaster};
|
||||
|
||||
use super::tasting_notes::{self, TastingNoteView};
|
||||
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info};
|
||||
use super::{
|
||||
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
|
||||
};
|
||||
|
||||
pub struct RoastView {
|
||||
pub id: String,
|
||||
|
|
@ -67,18 +69,8 @@ impl RoastView {
|
|||
let producer = producer.unwrap_or_else(|| "—".to_string());
|
||||
let process = process.unwrap_or_else(|| "—".to_string());
|
||||
let created_at_sort_key = created_at.timestamp();
|
||||
let tasting_notes = tasting_notes
|
||||
.into_iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|segment| segment.trim().to_string())
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|note| tasting_notes::categorize(¬e))
|
||||
.collect();
|
||||
let created_date = created_at.format("%Y-%m-%d").to_string();
|
||||
let created_time = created_at.format("%H:%M").to_string();
|
||||
let tasting_notes = tasting_notes::parse_and_categorize(&tasting_notes);
|
||||
let (created_date, created_time) = format_datetime(created_at);
|
||||
let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}");
|
||||
|
||||
Self {
|
||||
|
|
@ -131,14 +123,9 @@ impl RoastDetailView {
|
|||
let coffee = build_coffee_info(&roast);
|
||||
let roaster_info = build_roaster_info(roaster);
|
||||
|
||||
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||
if let Some(ref o) = roast.origin
|
||||
&& !o.is_empty()
|
||||
{
|
||||
map_entries.push((o.as_str(), 2));
|
||||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
let (map_countries, map_max, legend_entries) =
|
||||
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
|
||||
let (created_date, created_time) = format_datetime(roast.created_at);
|
||||
|
||||
Self {
|
||||
id: roast.id.to_string(),
|
||||
|
|
@ -157,18 +144,9 @@ impl RoastDetailView {
|
|||
roaster_homepage: roaster_info.homepage,
|
||||
map_countries,
|
||||
map_max,
|
||||
legend_entries: vec![
|
||||
LegendEntry {
|
||||
label: "Origin",
|
||||
opacity: "",
|
||||
},
|
||||
LegendEntry {
|
||||
label: "Roaster",
|
||||
opacity: "opacity-50",
|
||||
},
|
||||
],
|
||||
created_date: roast.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: roast.created_at.format("%H:%M").to_string(),
|
||||
legend_entries,
|
||||
created_date,
|
||||
created_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,21 @@ pub fn categorize(note: &str) -> TastingNoteView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Split raw tasting note strings on commas and newlines, trim whitespace,
|
||||
/// drop empties, and categorise each resulting segment.
|
||||
pub fn parse_and_categorize(notes: &[String]) -> Vec<TastingNoteView> {
|
||||
notes
|
||||
.iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|n| categorize(&n))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Exact matches ────────────────────────────────────────────────────
|
||||
|
||||
fn exact_match(lower: &str) -> Option<NoteCategory> {
|
||||
|
|
|
|||
|
|
@ -125,17 +125,7 @@ impl TimelineEventView {
|
|||
Self::add_country_flags(&mut mapped_details);
|
||||
|
||||
let tasting_notes = if entity_type == EntityType::Roast {
|
||||
let notes = tasting_notes
|
||||
.into_iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|segment| segment.trim().to_string())
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|note| tasting_notes::categorize(¬e))
|
||||
.collect::<Vec<_>>();
|
||||
Some(notes)
|
||||
Some(tasting_notes::parse_and_categorize(&tasting_notes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue