Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/target

PLAN.md
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ curl -fsSL https://headsdown.app/install.sh | sh
# Authenticate (opens browser for approval)
hd auth

# Check your current status
# Check your current status and availability
hd status
hd availability
hd windows

# Set yourself to busy for 2 hours
hd busy 2h
Expand All @@ -54,6 +56,8 @@ hd watch
|---------|-------------|
| `hd auth` | Authenticate via Device Flow (browser-based) |
| `hd status` | Show your current availability |
| `hd availability [--at <rfc3339>]` | Show availability resolution and next transition |
| `hd windows` | List configured reachability windows |
| `hd whoami` | Show your authenticated identity |
| `hd busy [duration]` | Set mode to busy |
| `hd online` | Set mode to online |
Expand All @@ -72,6 +76,8 @@ hd watch
| `hd alias remove NAME` | Remove an alias |
| `hd alias list` | List all aliases |
| `hd telemetry on\|off` | Toggle anonymous usage telemetry |
| `hd calibration on\|off\|status` | Manage calibration reporting |
| `hd outcome <proposal_id> <outcome>` | Report the real task outcome |
| `hd completions <shell>` | Generate shell completions (bash, zsh, fish) |

## Duration Formats
Expand Down
112 changes: 112 additions & 0 deletions src/commands/availability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use anyhow::Result;
use chrono::{DateTime, Utc};

use crate::auth;
use crate::client::GraphQLClient;
use crate::format;

const AVAILABILITY_QUERY: &str = r#"
query Availability($at: DateTime) {
availability(at: $at) {
inReachableHours
nextTransitionAt
activeWindow {
id
label
mode
startTime
endTime
days
}
nextWindow {
id
label
mode
startTime
endTime
days
}
}
}
"#;

pub async fn run(api_url: &str, at: Option<String>, json: bool) -> Result<()> {
let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);

let variables = serde_json::json!({ "at": at });
let data = client.execute(AVAILABILITY_QUERY, Some(variables)).await?;

if json {
println!("{}", serde_json::to_string_pretty(&data["availability"])?);
return Ok(());
}

let availability = &data["availability"];

println!();
println!(" {}", format::styled_bold("Availability"));
println!();

if let Some(in_hours) = availability["inReachableHours"].as_bool() {
let state = if in_hours {
"Reachable now"
} else {
"Not reachable now"
};
println!(" {} {}", format::styled_dimmed("State:"), state);
}

if let Some(label) = availability["activeWindow"]["label"].as_str() {
let mode = availability["activeWindow"]["mode"]
.as_str()
.unwrap_or("UNKNOWN")
.to_uppercase();
let days = availability["activeWindow"]["days"].as_str().unwrap_or("-");
let start = availability["activeWindow"]["startTime"]
.as_str()
.unwrap_or("-");
let end = availability["activeWindow"]["endTime"]
.as_str()
.unwrap_or("-");
println!(
" {} {} ({})",
format::styled_dimmed("Active:"),
label,
format::color_mode(&mode)
);
println!(
" {} {} {}-{}",
format::styled_dimmed("Hours:"),
days,
start,
end
);
}

if let Some(label) = availability["nextWindow"]["label"].as_str() {
let mode = availability["nextWindow"]["mode"]
.as_str()
.unwrap_or("UNKNOWN")
.to_uppercase();
println!(
" {} {} ({})",
format::styled_dimmed("Next window:"),
label,
format::color_mode(&mode)
);
}

if let Some(next_transition) = availability["nextTransitionAt"].as_str() {
if let Ok(next_at) = next_transition.parse::<DateTime<Utc>>() {
println!(
" {} {}",
format::styled_dimmed("Next change:"),
next_at.format("%a %b %-d %l:%M %p UTC")
);
}
}

println!();
Ok(())
}
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod alias;
pub mod auth;
pub mod availability;
pub mod calibration_cmd;
pub mod doctor;
pub mod hooks;
Expand All @@ -12,3 +13,4 @@ pub mod update;
pub mod verdict;
pub mod watch;
pub mod whoami;
pub mod windows;
1 change: 0 additions & 1 deletion src/commands/mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ pub async fn run(api_url: &str, mode: &str, duration: Option<String>, json: bool

let mut input = serde_json::json!({
"mode": mode,
"afk": mode == "OFFLINE",
"autoRespond": mode == "BUSY",
"status": false,
});
Expand Down
1 change: 1 addition & 0 deletions src/commands/outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mutation ReportOutcome($input: OutcomeInput!) {
}
"#;

#[allow(clippy::too_many_arguments)]
pub async fn run(
api_url: &str,
proposal_id: &str,
Expand Down
60 changes: 33 additions & 27 deletions src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ query {
duration
lock
}
calendar {
day
endsAt
workHours
offHours
availability {
inReachableHours
nextTransitionAt
activeWindow {
label
mode
}
nextWindow {
label
mode
}
}
profile {
name
Expand All @@ -39,7 +45,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> {
}

let contract = &data["activeContract"];
let calendar = &data["calendar"];
let availability = &data["availability"];
let profile = &data["profile"];

let name = profile["name"].as_str().unwrap_or("Unknown");
Expand Down Expand Up @@ -88,19 +94,27 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> {
}
}

// Work hours info
if let Some(true) = calendar["offHours"].as_bool() {
println!(
" {} {}",
format::styled_dimmed("Schedule:"),
format::styled_dimmed("Off hours")
);
} else if let Some(day) = calendar["day"].as_str() {
println!(
" {} Work hours ({})",
format::styled_dimmed("Schedule:"),
capitalize(day)
);
if let Some(in_hours) = availability["inReachableHours"].as_bool() {
let state = if in_hours {
"Reachable now"
} else {
"Not reachable now"
};
println!(" {} {}", format::styled_dimmed("Availability:"), state);
}

if let Some(label) = availability["activeWindow"]["label"].as_str() {
println!(" {} {}", format::styled_dimmed("Window:"), label);
}

if let Some(next_transition) = availability["nextTransitionAt"].as_str() {
if let Ok(next_at) = next_transition.parse::<DateTime<Utc>>() {
println!(
" {} {}",
format::styled_dimmed("Next change:"),
next_at.format("%l:%M %p").to_string().trim()
);
}
}

if contract["lock"].as_bool() == Some(true) {
Expand All @@ -114,11 +128,3 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> {
println!();
Ok(())
}

fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(),
}
}
6 changes: 6 additions & 0 deletions src/commands/verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const SUBMIT_PROPOSAL_MUTATION: &str = r#"
mutation SubmitProposal($input: ProposalInput!) {
submitProposal(input: $input) {
decision
policy
policyStatus
reason
proposalId
evaluatedAt
Expand Down Expand Up @@ -57,6 +59,8 @@ pub async fn run(
.as_str()
.unwrap_or("UNKNOWN")
.to_uppercase();
let policy = verdict["policy"].as_str().unwrap_or("UNKNOWN");
let policy_status = verdict["policyStatus"].as_str().unwrap_or("UNKNOWN");
let reason = verdict["reason"].as_str().unwrap_or("No reason provided");

println!();
Expand All @@ -66,6 +70,8 @@ pub async fn run(
format::color_verdict(&decision)
);
println!();
println!(" {} {}", format::styled_dimmed("Policy:"), policy);
println!(" {} {}", format::styled_dimmed("State:"), policy_status);
println!(" {} {}", format::styled_dimmed("Reason:"), reason);

// Show the proposal details
Expand Down
73 changes: 37 additions & 36 deletions src/commands/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ query {
duration
lock
}
calendar {
day
endsAt
workHours
offHours
availability {
inReachableHours
nextTransitionAt
activeWindow {
label
mode
}
}
profile {
name
Expand Down Expand Up @@ -56,7 +58,7 @@ pub async fn run(api_url: &str) -> Result<()> {
Ok(data) => {
let contract = &data["activeContract"];
let profile = &data["profile"];
let calendar = &data["calendar"];
let availability = &data["availability"];

let name = profile["name"].as_str().unwrap_or("Unknown");
let mode = contract["mode"]
Expand All @@ -67,8 +69,8 @@ pub async fn run(api_url: &str) -> Result<()> {
// Clear previous output (move cursor up and clear lines)
// On first render, don't clear
if !last_mode.is_empty() {
// Move up 6 lines and clear them
for _ in 0..6 {
// Move up 7 lines and clear them
for _ in 0..7 {
print!("\x1b[A\x1b[2K");
}
}
Expand Down Expand Up @@ -128,29 +130,36 @@ pub async fn run(api_url: &str) -> Result<()> {
println!(" {} -", format::styled_dimmed("Time:"));
}

// Schedule
if let Some(true) = calendar["offHours"].as_bool() {
println!(
" {} {}",
format::styled_dimmed("Schedule:"),
format::styled_dimmed("Off hours")
);
} else if let Some(day) = calendar["day"].as_str() {
println!(
" {} Work hours ({})",
format::styled_dimmed("Schedule:"),
capitalize(day)
);
if let Some(in_hours) = availability["inReachableHours"].as_bool() {
let state = if in_hours {
"Reachable now"
} else {
"Not reachable now"
};
println!(" {} {}", format::styled_dimmed("Availability:"), state);
} else {
println!(" {} -", format::styled_dimmed("Schedule:"));
println!(" {} -", format::styled_dimmed("Availability:"));
}

// Updated at
println!(
" {} {}",
format::styled_dimmed("Updated:"),
format::styled_dimmed(&Utc::now().format("%H:%M:%S").to_string())
);
if let Some(label) = availability["activeWindow"]["label"].as_str() {
println!(" {} {}", format::styled_dimmed("Window:"), label);
} else {
println!(" {} -", format::styled_dimmed("Window:"));
}

if let Some(next_transition) = availability["nextTransitionAt"].as_str() {
if let Ok(next_at) = next_transition.parse::<DateTime<Utc>>() {
println!(
" {} {}",
format::styled_dimmed("Next change:"),
format::styled_dimmed(next_at.format("%l:%M %p").to_string().trim())
);
} else {
println!(" {} -", format::styled_dimmed("Next change:"));
}
} else {
println!(" {} -", format::styled_dimmed("Next change:"));
}

io::stdout().flush().ok();

Expand Down Expand Up @@ -185,11 +194,3 @@ pub async fn run(api_url: &str) -> Result<()> {
println!();
Ok(())
}

fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(),
}
}
Loading
Loading