diff --git a/Cargo.lock b/Cargo.lock index bda107d2..cb6e7fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2507,6 +2507,7 @@ dependencies = [ "clap_mangen", "colog", "confy", + "console", "crossbeam", "crossterm", "date_time_parser", @@ -2542,6 +2543,7 @@ dependencies = [ "serde_json", "strum 0.26.3", "tabled", + "terminal-link", "textplots", "thiserror 2.0.11", "tokio", @@ -4546,6 +4548,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "253bcead4f3aa96243b0f8fa46f9010e87ca23bd5d0c723d474ff1d2417bbdf8" + [[package]] name = "terminal_size" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 03cd5a54..84f00cca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,8 @@ pretty_env_logger = "0.5.0" textplots = "0.8.6" valuable = "0.1.1" derive = "1.0.0" +console = "0.15" +terminal-link = "0.1" [features] default = ["tui"] diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index a2001766..00000000 --- a/docs/architecture.md +++ /dev/null @@ -1,21 +0,0 @@ -# Product Architecture - -```mermaid -erDiagram - INGESTION }|..|{ SUBSTANCE: "uses" - SUBSTANCE ||--o{ ROUTE_OF_ADMINISTRATION: "has multiple" - ROUTE_OF_ADMINISTRATION ||--o{ ROUTE_OF_ADMINISTRATION_DOSAGE: "has multiple" - ROUTE_OF_ADMINISTRATION ||--o{ ROUTE_OF_ADMINISTRATION_PHASE: "has multiple" - COMPOSITE ||--o{ COMPOSITE_COMPONENT: "has multiple" - STACK ||--o{ STACK_ITEM: "has multiple" - STACK_ITEM ||..o| INGESTION: "references" -``` - -- Composite: Defines a preset of multiple ingestions that are used together by ingestion of higher-order substance. Good - example is a meal, supplement capsules which contain multiple substances or a cocktail. - - Composite Component: Defines a single ingestion that is part of a composite. -- Stack: Defines a cyclic set of ingestions as of composite. Good example is a stack of supplements that are taken - together. - - StackItem: Defines a single ingestion that is part of a stack. -- Inventory: Defines a set of substances that are available for ingestion. - - InventoryItem: Defines a single substance that is part of an inventory. \ No newline at end of file diff --git a/docs/cli/message-formatting.md b/docs/cli/message-formatting.md new file mode 100644 index 00000000..0d2f350c --- /dev/null +++ b/docs/cli/message-formatting.md @@ -0,0 +1,48 @@ +# Message Formatting + +Application offers non-interactive functionality which would modify the way of formatting information piped to `stdout`, +meaning you can use application's data that you would normally see in a table by other software programs. + +Non-interactive mode would activate automatically when the environment in which the application is running is not +interactive (with use of a cargo library `atty` which contains complete specification on which conditions shell are +considered interactive or not). + +Non-interactive mode is de-facto modifying underlying logic for stdout formatting, application expose `--format` +argument which forces usage of specified formatter for every of the following commands that is run by the application. + +```bash +neuronek -f json ingestion list +``` + +```json +[ + { + "id": 1, + "substance_name": "caffeine", + "route": "Oral", + "dosage": "10.0 mg", + "ingested_at": "2025-01-06 04:40:27.253301" + } +] +``` + +## Examples + +### Pipe command output to another program + +Non-interactive mode would automatically activate when you are piping output, which allows you to use any program that +can ingest JSON and make use of it. Example shows how the application is used to list ingestion's and then use Nushell's +JSON parser to build a pretty table, which is the default way nushell shows information. + +```nu +> neuronek ingestion list | from json +╭───┬────┬────────────────┬───────┬─────────┬────────────────────────────╮ +│ # │ id │ substance_name │ route │ dosage │ ingested_at │ +├───┼────┼────────────────┼───────┼─────────┼────────────────────────────┤ +│ 0 │ 1 │ caffeine │ Oral │ 10.0 mg │ 2025-01-06 04:40:27.253301 │ +╰───┴────┴────────────────┴───────┴─────────┴────────────────────────────╯ +``` + +## References + +- [clig.dev](https://clig.dev/#output) \ No newline at end of file diff --git a/docs/composite.md b/docs/composite.md index 7072ec1b..5cf90aac 100644 --- a/docs/composite.md +++ b/docs/composite.md @@ -1 +1,16 @@ -# Composite \ No newline at end of file +# Composite + +Composite is a collection of various dosages of substances typically ingested through same route of administration, +think about it as pills that you might have which contain few supplement. This entity would represent a container with +multiple things inside, could be actually useful to avoid necessary scripting for making a set of ingestions. + +- `Composite` + - id + - name + - description + - route_of_administration + - form + - items (`CompositeItems`) + - id + - substance_name + - substance_dosage \ No newline at end of file diff --git a/docs/dosage.md b/docs/dosage.md new file mode 100644 index 00000000..0dc0d19e --- /dev/null +++ b/docs/dosage.md @@ -0,0 +1,5 @@ +# Dosage + +**Dosage** is treated as a core concept for capturing the mass (or amount) of a substance ingested. This dosage is +stored in the database for each ingestion record, along with other relevant data such as substance name, route of +administration, and ingestion timestamps. \ No newline at end of file diff --git a/docs/ingestion-analyzer.md b/docs/ingestion-analyzer.md new file mode 100644 index 00000000..f9972ce1 --- /dev/null +++ b/docs/ingestion-analyzer.md @@ -0,0 +1,4 @@ +# Ingestion Analyzer + +Ingestion Analyzer is a internal engine to use set of predefined rules and user's ingestion data to disadvise or provide +additional insight for ingestion that one is trying to ingest or actually ingested. \ No newline at end of file diff --git a/docs/ingestion.md b/docs/ingestion.md index e69de29b..7227a758 100644 --- a/docs/ingestion.md +++ b/docs/ingestion.md @@ -0,0 +1,36 @@ +# Ingestion + +The **Ingestion** entity represents a single record of consuming a specific [substance](./substance.md). It captures +details such as the substance name, dosage, route of administration, and the date/time at which the ingestion occurred. +The entity also supports optional classification of the dosage and calculation of various time-based phases (e.g., +onset, peak) related to the ingestion experience. + +## Data Model + +- Is uniuqely identified. +- Must contain minimal amount of data to identify substance. +- Must contain minimal amount of data about dosage. + - Might support dosage ranges and unknown dosages. +- Must contain time when was ingested + - Could support ranges and unknowns probably. +- Can contain dosage classification +- Can contain reference to substance in database +- Can contain multiple `IngestionPhase` entities which define calendar entries of specific phases of ingestion, it's + created when ingestion analysis was possible and application had enough infomration. + +## Flow + +1. **Create/Update** + When a new ingestion is recorded, the application stores the substance details, dosage, and route. If dosage + information matches known reference ranges, a dosage_classification can be assigned. Any known phases for this + ingestion are also stored. +2. **Retrieval** + When retrieving an ingestion, relevant ingestion-phase records are also fetched, providing a comprehensive view of + both the ingestion event and its associated timeline of effects. +3. **Analysis** + Systems or modules that analyze ingestion data (e.g., generating reports, running calculations) can use the dosage, + route, and phase data to present meaningful insights to end users. +4. **Intended Usage** + This data structure is intended to help track ingestions reliably while linking them to known durations and + intensities, and to assist in historical lookups or analytics across ingestion events. + diff --git a/src/cli/ingestion.rs b/src/cli/ingestion.rs index 51165ad9..950f0845 100644 --- a/src/cli/ingestion.rs +++ b/src/cli/ingestion.rs @@ -6,6 +6,8 @@ use crate::core::QueryHandler; use crate::database::entities::ingestion; use crate::database::entities::ingestion::Entity as Ingestion; use crate::database::entities::ingestion::Model; +use crate::database::entities::ingestion_phase; +use crate::database::entities::ingestion_phase::Entity as IngestionPhase; use crate::ingestion::command::LogIngestion; use crate::ingestion::query::AnalyzeIngestion; use crate::substance::repository::get_substance; @@ -24,6 +26,7 @@ use clap::Subcommand; use log::info; use miette::IntoDiagnostic; use miette::miette; +use owo_colors::style; use sea_orm::ActiveModelTrait; use sea_orm::ActiveValue; use sea_orm::EntityTrait; @@ -32,18 +35,19 @@ use sea_orm::QuerySelect; use sea_orm_migration::IntoSchemaManagerConnection; use serde::Deserialize; use serde::Serialize; +use std::fmt::Debug; use std::str::FromStr; use tabled::Tabled; use tracing::Level; use tracing::event; use typed_builder::TypedBuilder; +use uuid::Uuid; #[async_trait] impl CommandHandler for LogIngestion { async fn handle<'a>(&self, context: AppContext<'a>) -> miette::Result<()> { - // Substance name powered by PubChem API, fallback to user input let substance_name = pubchem::Compound::with_name(&self.substance_name) .title() .into_diagnostic() @@ -51,7 +55,7 @@ impl CommandHandler for LogIngestion let ingestion = Ingestion::insert(ingestion::ActiveModel { id: ActiveValue::default(), - substance_name: ActiveValue::Set(substance_name.to_lowercase()), + substance_name: ActiveValue::Set(substance_name.to_lowercase().clone()), route_of_administration: ActiveValue::Set( serde_json::to_value(self.route_of_administration) .unwrap() @@ -76,28 +80,17 @@ impl CommandHandler for LogIngestion IngestionViewModel::from(ingestion.clone()).format(context.stdout_format) ); - // Perform ingestion analysis let analysis_query = AnalyzeIngestion::builder() - .ingestion_id(Some(ingestion.id)) - .substance(None) - .date(None) - .dosage(None) - .roa(None) + .substance(self.substance_name.clone()) + .date(self.ingestion_date) + .dosage(self.dosage) + .roa(self.route_of_administration) .build(); match analysis_query.query().await { | Ok(analysis) => { - println!("\nIngestion Analysis:"); - if let Some(dosage_class) = analysis.dosage_classification - { - println!("Dosage Classification: {:?}", dosage_class); - } - - println!("Start Time: {}", HumanTime::from(analysis.ingestion_date)); - - // Update ingestion with analysis results if needed if analysis.dosage_classification.is_some() { let update_model = ingestion::ActiveModel { @@ -112,6 +105,35 @@ impl CommandHandler for LogIngestion .update(context.database_connection) .await .into_diagnostic()?; + + if !analysis.phases.is_empty() + { + let phase_models = analysis + .phases + .into_iter() + .map(|phase| ingestion_phase::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4().to_string()), + ingestion_id: ActiveValue::Set(ingestion.id), + classification: ActiveValue::Set(phase.class.to_string()), + duration_lower: ActiveValue::Set( + phase.duration.start.num_minutes().to_string(), + ), + duration_upper: ActiveValue::Set( + phase.duration.end.num_minutes().to_string(), + ), + intensity: ActiveValue::NotSet, + notes: ActiveValue::NotSet, + description: ActiveValue::NotSet, + created_at: ActiveValue::Set(phase.start_time.naive_utc()), + updated_at: ActiveValue::Set(phase.end_time.naive_utc()), + }) + .collect::>(); + + IngestionPhase::insert_many(phase_models) + .exec(context.database_connection) + .await + .into_diagnostic()?; + } } } | Err(e) => @@ -122,10 +144,6 @@ impl CommandHandler for LogIngestion error = ?e, ingestion_id = ingestion.id ); - println!( - "\nWarning: Could not perform full ingestion analysis: {}", - e - ); } } @@ -346,6 +364,7 @@ pub struct IngestionViewModel } impl Formatter for IngestionViewModel {} + impl From for IngestionViewModel { fn from(model: Model) -> Self diff --git a/src/cli/substance.rs b/src/cli/substance.rs index 4ef78b1d..14644129 100644 --- a/src/cli/substance.rs +++ b/src/cli/substance.rs @@ -1,9 +1,8 @@ -use async_trait::async_trait; use crate::core::CommandHandler; -use crate::cli::formatter::Formatter; -use crate::substance::error::SubstanceError; use crate::substance::SubstanceTable; +use crate::substance::error::SubstanceError; use crate::utils::AppContext; +use async_trait::async_trait; use clap::Args; use clap::Parser; use clap::Subcommand; @@ -150,8 +149,6 @@ pub struct ViewModel pub common_names: String, } -impl Formatter for ViewModel {} - impl From for ViewModel { fn from(model: SubstanceTable) -> Self diff --git a/src/core/logging.rs b/src/core/logging.rs index c1cbb549..f9c4842b 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -1,19 +1,20 @@ -use etcetera::base_strategy::{BaseStrategy, Xdg}; +use etcetera::base_strategy::BaseStrategy; +use etcetera::base_strategy::Xdg; use std::fs::create_dir_all; -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_appender::non_blocking::WorkerGuard; - -pub fn setup_logger() -> Result> { +use tracing_appender::rolling::RollingFileAppender; +use tracing_appender::rolling::Rotation; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt; +use tracing_subscriber::prelude::*; + +pub fn setup_logger() -> Result> +{ let xdg = Xdg::new()?.cache_dir().join("neuronek").join("logs"); create_dir_all(&xdg)?; - let file_appender = RollingFileAppender::new( - Rotation::DAILY, - &xdg, - "neuronek" - ); - + let file_appender = RollingFileAppender::new(Rotation::DAILY, &xdg, "neuronek"); + let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender); let file_layer = fmt::layer() @@ -23,32 +24,12 @@ pub fn setup_logger() -> Result> { .with_target(false) .with_writer(non_blocking_appender); - - // #[cfg(debug_assertions)] - // let console_layer = { - // fmt::layer() - // .with_target(false) - // .with_thread_ids(false) - // .with_file(false) - // .with_line_number(false) - // .with_ansi(true) - // .compact() - // .pretty() - // .with_writer(std::io::stderr) - // }; - let registry = tracing_subscriber::registry() - .with( - EnvFilter::from_default_env() - .add_directive(tracing::Level::INFO.into()) - ) + .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) .with(file_layer); - - // #[cfg(debug_assertions)] - // let registry = registry.with(console_layer); registry.init(); - + Ok(guard) } diff --git a/src/database/entities/ingestion_phase.rs b/src/database/entities/ingestion_phase.rs index d3e50937..fec409e6 100644 --- a/src/database/entities/ingestion_phase.rs +++ b/src/database/entities/ingestion_phase.rs @@ -1,6 +1,5 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.4 - use sea_orm::entity::prelude::*; use serde::Deserialize; use serde::Serialize; @@ -16,12 +15,10 @@ pub struct Model pub classification: String, #[sea_orm(column_type = "Text", nullable)] pub description: Option, - pub start_time: DateTime, - pub end_time: DateTime, #[sea_orm(column_type = "Text", nullable)] - pub duration_lower: Option, + pub duration_lower: String, #[sea_orm(column_type = "Text", nullable)] - pub duration_upper: Option, + pub duration_upper: String, #[sea_orm(column_type = "Text", nullable)] pub intensity: Option, #[sea_orm(column_type = "Text", nullable)] diff --git a/src/ingestion/model.rs b/src/ingestion/model.rs index b9e8cfaf..2c5475c3 100644 --- a/src/ingestion/model.rs +++ b/src/ingestion/model.rs @@ -62,18 +62,25 @@ type PhaseSchedule = HashMap; pub struct IngestionPhase { pub(crate) id: Option, - pub(crate) class: crate::substance::route_of_administration::phase::PhaseClassification, - /// The typical earliest time offset and typical + pub(crate) class: PhaseClassification, + pub(crate) start_time: DateTime, + pub(crate) end_time: DateTime, pub(crate) duration: PhaseDuration, } impl From for IngestionPhase { fn from(value: crate::database::entities::ingestion_phase::Model) -> Self { - IngestionPhase { + let duration_lower = value.duration_lower.parse::().unwrap_or(0.0); + let duration_upper = value.duration_upper.parse::().unwrap_or(0.0); + + Self { id: Some(value.id), class: PhaseClassification::from_str(&*value.classification).unwrap(), - duration: Range::default(), + start_time: Local.from_utc_datetime(&value.created_at), + end_time: Local.from_utc_datetime(&value.updated_at), + duration: Duration::minutes(duration_lower as i64) + ..Duration::minutes(duration_upper as i64), } } } diff --git a/src/ingestion/query.rs b/src/ingestion/query.rs index 478e4be3..b23c8297 100644 --- a/src/ingestion/query.rs +++ b/src/ingestion/query.rs @@ -4,16 +4,19 @@ use crate::database::entities::ingestion; use crate::database::entities::ingestion::Entity as IngestionEntity; use crate::database::entities::ingestion_phase; use crate::ingestion::model::Ingestion; +use crate::ingestion::model::IngestionPhase; use crate::substance::repository::get_substance; +use crate::substance::route_of_administration::RouteOfAdministration; use crate::substance::route_of_administration::RouteOfAdministrationClassification; use crate::substance::route_of_administration::dosage::Dosage; use crate::substance::route_of_administration::dosage::DosageClassification; -use crate::substance::route_of_administration::dosage::classify_dosage; +use crate::substance::route_of_administration::dosage::classify_dosage as other_classify_dosage; use crate::substance::route_of_administration::phase::PhaseClassification; use crate::utils::AppContext; use async_trait::async_trait; use chrono::DateTime; use chrono::Duration; +use chrono::Duration as TimeDelta; use chrono::Local; use chrono::TimeZone; use clap::Parser; @@ -23,11 +26,13 @@ use derive_more::FromStr; use miette::IntoDiagnostic; use sea_orm::ActiveModelTrait; use sea_orm::ActiveValue; +use sea_orm::DatabaseConnection; use sea_orm::EntityTrait; use sea_orm::QueryOrder; use sea_orm::QuerySelect; use sea_orm_migration::IntoSchemaManagerConnection; use typed_builder::TypedBuilder; +use uuid::Uuid; #[derive(Parser, Debug, Copy, Clone, Serialize, Deserialize, TypedBuilder)] #[command(version, about = "Query ingestions", long_about, aliases = vec!["ls", "get"])] @@ -52,27 +57,17 @@ pub struct ListIngestion )] pub struct AnalyzeIngestion { - /// Identifier of the ingestion to analyze. - #[arg( - short, - long, - help = "The identifier of the ingestion entry to analyze", - aliases = vec!["id"], - conflicts_with_all = &["substance", "dosage", "date", "roa"] - )] - pub ingestion_id: Option, - - /// Name of the substance involved in the ingestion (if not using - /// `ingestion_id`). - #[arg( - short, - long, - value_name = "SUBSTANCE", - help = "Name of the substance", - requires = "dosage", - conflicts_with = "ingestion_id" - )] - pub substance: Option, + /// Name of the substance involved in the ingestion. + // #[arg( + // short, + // long, + // value_name = "SUBSTANCE", + // help = "Name of the substance", + // requires = "dosage", + // conflicts_with = "ingestion_id" + // )] + #[arg(short, long, value_name = "SUBSTANCE")] + pub substance: String, /// Dosage of the substance ingested (if not using `ingestion_id`). #[arg( @@ -80,11 +75,9 @@ pub struct AnalyzeIngestion long, value_name = "DOSAGE", help = "Dosage of the substance", - requires = "substance", - conflicts_with = "ingestion_id", value_parser = Dosage::from_str, )] - pub dosage: Option, + pub dosage: Dosage, /// Date of ingestion (defaults to the current date if not provided). #[arg( @@ -92,19 +85,12 @@ pub struct AnalyzeIngestion long = "date", default_value = "now", value_parser = crate::utils::parse_date_string, - conflicts_with = "ingestion_id" )] - pub date: Option>, + pub date: chrono::DateTime, /// Route of administration of the substance (defaults to "oral"). - #[arg( - short = 'r', - long = "roa", - default_value = "oral", - value_enum, - conflicts_with = "ingestion_id" - )] - pub roa: Option, + #[arg(short = 'r', long = "roa", default_value = "oral", value_enum)] + pub roa: RouteOfAdministrationClassification, } impl From for AnalyzeIngestion @@ -112,11 +98,10 @@ impl From for AnalyzeIngestion fn from(ingestion: super::model::Ingestion) -> Self { AnalyzeIngestion { - ingestion_id: ingestion.id, - substance: Some(ingestion.substance_name), - dosage: Some(ingestion.dosage), - date: Some(ingestion.ingestion_date), - roa: Some(ingestion.route), + substance: ingestion.substance_name, + dosage: ingestion.dosage, + date: ingestion.ingestion_date, + roa: ingestion.route, } } } @@ -146,8 +131,145 @@ impl crate::core::QueryHandler> for ListIngestion } } +/// Calculate phase timeline for an ingestion based on ROA phase definitions +fn calculate_phases( + ingestion_date: DateTime, + roa: &RouteOfAdministration, +) -> Vec +{ + let mut phases = Vec::new(); + let mut current_time = ingestion_date; + + // Process phases in order: Onset, Comeup, Peak, Comedown, Afterglow + for phase_class in &[ + PhaseClassification::Onset, + PhaseClassification::Comeup, + PhaseClassification::Peak, + PhaseClassification::Comedown, + PhaseClassification::Afterglow, + ] + { + if let Some(duration_range) = roa.phases.get(phase_class) + { + let start_minutes = duration_range.start.num_minutes().unwrap_or(0.0); + let end_minutes = duration_range.end.num_minutes().unwrap_or(0.0); + let duration_minutes = (start_minutes + end_minutes) / 2.0; + let phase_duration = TimeDelta::minutes(duration_minutes as i64); + let end_time = current_time + phase_duration; + + phases.push(IngestionPhase { + id: Some(Uuid::new_v4().to_string()), + class: *phase_class, + start_time: current_time, + end_time, + duration: TimeDelta::zero()..phase_duration, + }); + + current_time = end_time; + } + } + + phases +} + #[async_trait] impl QueryHandler for AnalyzeIngestion { - async fn query(&self) -> miette::Result { todo!() } + async fn query(&self) -> miette::Result + { + let db: &DatabaseConnection = &DATABASE_CONNECTION; + + let substance_name = &self.substance.clone(); + let dosage = self.dosage; + let date = self.date; + let route = self.roa; + + let substance = get_substance(substance_name, db) + .await + .map_err(|e| miette::miette!("Failed to get substance: {}", e))?; + + let mut ingestion = Ingestion { + id: None, + substance_name: substance_name.clone(), + dosage, + route, + ingestion_date: date, + dosage_classification: None, + substance: substance.map(Box::new), + phases: Vec::new(), + }; + + if let Some(substance) = &ingestion.substance + { + if let Some(roa) = substance.routes_of_administration.get(&route) + { + let dosage_value = dosage.as_base_units(); + ingestion.dosage_classification = classify_dosage_local(dosage_value, roa); + + ingestion.phases = calculate_phases(date, roa); + } + } + + Ok(ingestion) + } +} + +/// Classifies a dosage value based on the route of administration's thresholds +fn classify_dosage_local( + dosage_value: f64, + roa: &RouteOfAdministration, +) -> Option +{ + let threshold = roa + .dosages + .get(&DosageClassification::Threshold)? + .start? + .as_base_units(); + let light = roa + .dosages + .get(&DosageClassification::Light)? + .start? + .as_base_units(); + let common = roa + .dosages + .get(&DosageClassification::Common)? + .start? + .as_base_units(); + let strong = roa + .dosages + .get(&DosageClassification::Strong)? + .start? + .as_base_units(); + let heavy = roa + .dosages + .get(&DosageClassification::Heavy)? + .start? + .as_base_units(); + + Some( + if dosage_value <= threshold + { + DosageClassification::Threshold + } + else if dosage_value <= light + { + DosageClassification::Light + } + else if dosage_value <= common + { + DosageClassification::Common + } + else if dosage_value <= strong + { + DosageClassification::Strong + } + else if dosage_value <= heavy + { + DosageClassification::Heavy + } + else + { + DosageClassification::Heavy + }, + ) }