Skip to content

Commit 93d8f13

Browse files
committed
feat: add conference status summary command and related types
1 parent d81a252 commit 93d8f13

7 files changed

Lines changed: 446 additions & 0 deletions

File tree

src/commands/admin_status.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use anyhow::Result;
2+
3+
use super::require_client;
4+
use crate::display;
5+
use crate::types::ConferenceStatusSummary;
6+
use crate::ui;
7+
8+
pub async fn run(json: bool) -> Result<()> {
9+
let client = require_client()?;
10+
11+
let sp = ui::spinner("Fetching conference status…");
12+
let summary: ConferenceStatusSummary = client.query("status.admin.summary", None).await?;
13+
sp.finish_and_clear();
14+
15+
if json {
16+
let raw = serde_json::to_string_pretty(&serde_json::to_value(&summary)?)?;
17+
println!("{raw}");
18+
} else {
19+
display::print_status(&summary);
20+
}
21+
22+
Ok(())
23+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use anyhow::{Context, Result};
33
use crate::client::TrpcClient;
44
use crate::config;
55

6+
pub mod admin_status;
67
pub mod login;
78
pub mod logout;
89
pub mod proposals;

src/display/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
mod proposal;
22
mod sponsor;
3+
mod status;
34

45
pub use proposal::{pad_and_colorize_status, print_proposal_detail, render_proposal_detail};
56
pub use sponsor::{
67
SPONSOR_TABLE_HEADER, format_sponsor_row, print_sponsor_detail, print_sponsor_list,
78
render_sponsor_detail,
89
};
10+
pub use status::print_status;

src/display/status.rs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use std::fmt::Write;
2+
3+
use colored::Colorize;
4+
5+
use crate::types::ConferenceStatusSummary;
6+
7+
pub fn print_status(summary: &ConferenceStatusSummary) {
8+
print!("{}", render_status(summary));
9+
}
10+
11+
fn render_status(summary: &ConferenceStatusSummary) -> String {
12+
let mut buf = String::new();
13+
14+
let date = summary
15+
.last_updated
16+
.split('T')
17+
.next()
18+
.unwrap_or(&summary.last_updated);
19+
20+
writeln!(
21+
buf,
22+
"{}",
23+
format!("📊 Conference Status — {}", summary.conference_title).bold()
24+
)
25+
.unwrap();
26+
writeln!(buf, " Summary as of {date}").unwrap();
27+
28+
if let Some(sp) = &summary.sponsors {
29+
render_sponsors(&mut buf, sp);
30+
}
31+
if let Some(pr) = &summary.proposals {
32+
render_proposals(&mut buf, pr);
33+
}
34+
if let Some(tk) = &summary.tickets {
35+
render_tickets(&mut buf, tk);
36+
}
37+
if let Some(tp) = &summary.target_progress {
38+
render_target_progress(&mut buf, tp, summary.tickets.as_ref());
39+
}
40+
if let Some(tk) = &summary.tickets
41+
&& tk.category_breakdown.len() > 1
42+
{
43+
render_category_breakdown(&mut buf, tk);
44+
}
45+
if !summary.errors.is_empty() {
46+
render_errors(&mut buf, &summary.errors);
47+
}
48+
49+
buf
50+
}
51+
52+
fn render_sponsors(buf: &mut String, sp: &crate::types::SponsorPipeline) {
53+
writeln!(buf).unwrap();
54+
writeln!(buf, "{}", "🤝 Sponsor Pipeline".bold()).unwrap();
55+
write_row(
56+
buf,
57+
"Total Sponsors:",
58+
sp.total_sponsors,
59+
"Active Deals:",
60+
sp.active_deals,
61+
);
62+
write_row(
63+
buf,
64+
"Closed Won:",
65+
sp.closed_won_count,
66+
"Closed Lost:",
67+
sp.closed_lost_count,
68+
);
69+
70+
if sp.total_contract_value > 0.0 {
71+
let closed = sp.closed_won_count + sp.closed_lost_count;
72+
let win_rate = if closed > 0 {
73+
#[allow(clippy::cast_precision_loss)]
74+
let rate = sp.closed_won_count as f64 / closed as f64 * 100.0;
75+
format!("{rate:.0}%")
76+
} else {
77+
"0%".to_string()
78+
};
79+
writeln!(
80+
buf,
81+
" {:<24}{:<6} {:<24}{win_rate}",
82+
"Total Contract Value:",
83+
format_currency(sp.total_contract_value, &sp.contract_currency),
84+
"Win Rate:",
85+
)
86+
.unwrap();
87+
}
88+
89+
for (label, map) in [
90+
("Pipeline Stages:", &sp.by_status),
91+
("Invoice Status:", &sp.by_invoice_status),
92+
("Contract Status:", &sp.by_contract_status),
93+
] {
94+
let text = format_map(map);
95+
if !text.is_empty() {
96+
writeln!(buf, " {label:<19}{text}").unwrap();
97+
}
98+
}
99+
}
100+
101+
fn render_proposals(buf: &mut String, pr: &crate::types::ProposalSummary) {
102+
writeln!(buf).unwrap();
103+
writeln!(buf, "{}", "📝 CFP / Proposals".bold()).unwrap();
104+
write_row(
105+
buf,
106+
"Total Proposals:",
107+
pr.total,
108+
"Submitted:",
109+
pr.submitted,
110+
);
111+
write_row(buf, "Accepted:", pr.accepted, "Confirmed:", pr.confirmed);
112+
if pr.rejected > 0 || pr.withdrawn > 0 {
113+
write_row(buf, "Rejected:", pr.rejected, "Withdrawn:", pr.withdrawn);
114+
}
115+
}
116+
117+
fn render_tickets(buf: &mut String, tk: &crate::types::TicketSummary) {
118+
writeln!(buf).unwrap();
119+
writeln!(buf, "{}", "🎟️ Tickets".bold()).unwrap();
120+
writeln!(
121+
buf,
122+
" {:<24}{:<6} {:<24}{}",
123+
"Paid Tickets:",
124+
tk.paid_tickets,
125+
"Total Revenue:",
126+
format_currency(tk.total_revenue, "kr")
127+
)
128+
.unwrap();
129+
130+
let complimentary = tk.sponsor_tickets + tk.speaker_tickets + tk.organizer_tickets;
131+
let comp_detail = format!(
132+
"{complimentary} (claimed {}, rate {:.1}%)",
133+
tk.free_tickets_claimed, tk.free_ticket_claim_rate
134+
);
135+
writeln!(
136+
buf,
137+
" {:<24}{:<6} {:<24}{comp_detail}",
138+
"Total Tickets:", tk.total_tickets, "Complimentary:",
139+
)
140+
.unwrap();
141+
}
142+
143+
fn render_target_progress(
144+
buf: &mut String,
145+
tp: &crate::types::TargetProgress,
146+
tickets: Option<&crate::types::TicketSummary>,
147+
) {
148+
writeln!(buf).unwrap();
149+
let emoji = if tp.is_on_track { "✅" } else { "⚠️" };
150+
writeln!(buf, "{}", format!("{emoji} Target Progress").bold()).unwrap();
151+
152+
let target = format!("{:.1}%", tp.target_percentage);
153+
writeln!(
154+
buf,
155+
" {:<24}{:<6} {:<24}{:.1}%",
156+
"Current Target:", target, "Actual Progress:", tp.current_percentage,
157+
)
158+
.unwrap();
159+
160+
let variance_text = if tp.variance >= 0.0 {
161+
format!("+{:.1}% ahead", tp.variance).green().to_string()
162+
} else {
163+
format!("{:.1}% behind", tp.variance).red().to_string()
164+
};
165+
if let Some(tk) = tickets {
166+
writeln!(
167+
buf,
168+
" {:<24}{:<30} {:<24}{}/{}",
169+
"Variance:", variance_text, "Capacity:", tk.paid_tickets, tp.capacity
170+
)
171+
.unwrap();
172+
} else {
173+
writeln!(buf, " {:<24}{variance_text}", "Variance:").unwrap();
174+
}
175+
176+
if let Some(m) = &tp.next_milestone {
177+
writeln!(
178+
buf,
179+
" 🎯 Next Milestone: {} in {} days",
180+
m.label, m.days_away
181+
)
182+
.unwrap();
183+
}
184+
}
185+
186+
fn render_category_breakdown(buf: &mut String, tk: &crate::types::TicketSummary) {
187+
writeln!(buf).unwrap();
188+
writeln!(buf, "{}", "Breakdown by Paid Ticket Category:".bold()).unwrap();
189+
let mut entries: Vec<_> = tk.category_breakdown.iter().collect();
190+
entries.sort_by(|a, b| b.1.cmp(a.1));
191+
for (cat, count) in entries {
192+
writeln!(buf, " {cat}: {count} tickets").unwrap();
193+
}
194+
}
195+
196+
fn render_errors(buf: &mut String, errors: &[crate::types::SectionError]) {
197+
writeln!(buf).unwrap();
198+
for e in errors {
199+
writeln!(
200+
buf,
201+
"{}",
202+
format!("⚠ {}: {}", e.section, e.message).yellow()
203+
)
204+
.unwrap();
205+
}
206+
}
207+
208+
fn write_row(buf: &mut String, l1: &str, v1: usize, l2: &str, v2: usize) {
209+
writeln!(buf, " {l1:<24}{v1:<6} {l2:<24}{v2}").unwrap();
210+
}
211+
212+
#[allow(clippy::cast_possible_truncation)]
213+
fn format_currency(amount: f64, currency: &str) -> String {
214+
let integer = amount as i64;
215+
let formatted = format_thousands(integer);
216+
format!("{formatted} {currency}")
217+
}
218+
219+
fn format_thousands(n: i64) -> String {
220+
let s = n.to_string();
221+
let bytes: Vec<u8> = s.bytes().rev().collect();
222+
let chunks: Vec<String> = bytes
223+
.chunks(3)
224+
.map(|c| c.iter().rev().map(|&b| b as char).collect())
225+
.collect();
226+
let mut result: Vec<String> = chunks;
227+
result.reverse();
228+
result.join("\u{00a0}")
229+
}
230+
231+
fn format_map(map: &std::collections::HashMap<String, usize>) -> String {
232+
let mut entries: Vec<_> = map.iter().filter(|(_, v)| **v > 0).collect();
233+
entries.sort_by(|a, b| b.1.cmp(a.1));
234+
entries
235+
.iter()
236+
.map(|(k, v)| format!("{k}: {v}"))
237+
.collect::<Vec<_>>()
238+
.join(" · ")
239+
}

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ enum AdminCommand {
3030
/// Manage sponsor pipeline
3131
#[command(subcommand)]
3232
Sponsors(SponsorCommand),
33+
/// Show conference status summary (sponsors, proposals, tickets, targets)
34+
Status {
35+
/// Output as JSON
36+
#[arg(long)]
37+
json: bool,
38+
},
3339
}
3440

3541
#[derive(Subcommand)]
@@ -80,6 +86,7 @@ async fn main() -> Result<()> {
8086
SponsorCommand::List(args) => commands::sponsors::list(args).await,
8187
SponsorCommand::Get { id } => commands::sponsors::get(&id).await,
8288
},
89+
AdminCommand::Status { json } => commands::admin_status::run(json).await,
8390
},
8491
}
8592
}

src/types/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
mod proposal;
22
mod sponsor;
3+
mod status;
34

45
pub use proposal::*;
56
pub use sponsor::*;
7+
pub use status::*;
68

79
use serde::Deserialize;
810

0 commit comments

Comments
 (0)