nunc 0.2.0

WIP: time tracking application
Documentation
use std::io::Write;

use conciliator::{
	Buffer,
	Inline,
	print::Formatter,
	Paint
};

use rusqlite::{
	Row,
	Result as SqlResult,
	Error as SqlError
};
use greg::{
	Point,
	Span
};

use crate::Nunc;
use crate::core::{
	Tree,
	Plan
};
use crate::util::format::LookupFormatter;

use super::{
	Activity,
	activity::ActOrCat,
	Category
};

#[derive(Debug, Eq, PartialEq)]
pub struct Streak {
	pub id: u64,
	pub interval: Span,
	pub begin: Point,
	pub end: Option<Point>,
	pub added: Point,
	pub removed: Option<Point>,
	pub rule: StreakRule
}

#[derive(Debug, Eq, PartialEq)]
pub enum StreakRule {
	Activity {
		activity_id: u64,
		min_duration: Span
	},
	Category {
		category_id: u64,
		min_duration: Span
	}
}

pub struct StreakIteration {
	pub streak_id: u64,
	pub number: u64,
	pub value: u64
}

pub mod state {
	use super::*;

	#[derive(Debug, Eq, PartialEq)]
	pub struct Raw {
		pub streak_id: u64,
		pub length: u64,
		pub next_iteration_at: Point,
		pub fulfilled: Span,
		pub unfulfilled: Span
	}

	pub struct Format {
		pub(crate) subject: ActOrCat,
		pub(crate) length: u64,
		pub(crate) next_iteration_at: Point,
		pub(crate) required: Span,
		pub(crate) fulfilled: Span
	}

	impl Raw {
		pub fn make_inline(
			&self,
			nunc: &Nunc)
			-> SqlResult<Format>
		{
			let streak = nunc.get_streak(self.streak_id)?;
			let fmt = Format {
				subject: streak.rule.get_subject(nunc)?,
				length: self.length,
				next_iteration_at: self.next_iteration_at,
				required: streak.rule.required(),
				fulfilled: self.fulfilled
			};
			Ok(fmt)
		}
	}

	impl Inline for Format {
		fn inline(&self, buffer: &mut Buffer) {
			let padding = 10usize.saturating_sub(self.subject.len());
			buffer
				.push_plain("[")
				.push_alpha_bold(&format_args!("{:4} ▲ ", self.length))
				.push(&self.subject)
				.push_plain(&format_args!("{:<padding$}\t", "]"));

			let until_next = self.next_iteration_at - Point::now();

			if self.required > self.fulfilled {
				buffer
					.push_bold("[ ")
					.push_iota_bold("")
					.push_bold(" ] ");
				if self.fulfilled.seconds > 0 {
					let percent = self.fulfilled.seconds as f32
						/ self.required.seconds as f32
						* 100.0;
					write!(buffer, "{percent:.0}% done, ").unwrap();
				}
				write!(
					buffer,
					"{until_next:#.2} left to do {:#}",
					self.required.checked_sub(self.fulfilled).unwrap()
				).unwrap();
			}
			else {
				buffer
					.push_bold("[ ")
					.push_alpha_bold("")
					.push_bold(" ] ");
				write!(
					buffer,
					"did {:#}, next in {until_next:#.2}",
					self.fulfilled
				).unwrap();
			}
		}
	}
}


/*
 *	STREAK
 */

impl<'r> TryFrom<&Row<'r>> for Streak {
	type Error = SqlError;

	fn try_from(value: &Row<'r>) -> Result<Self, Self::Error> {
		let (
			id,
			interval,
			begin,
			end,
			added,
			removed,
			rule_type,
			activity_id,
			min_duration,
			category_id
		) = value.try_into()?;
		let rule_type: String = rule_type;
		let rule = match (rule_type.as_str(), activity_id, category_id) {
			("activity", Some(activity_id), None) => StreakRule::Activity {
				activity_id,
				min_duration
			},
			("category", None, Some(category_id)) => StreakRule::Category {
				category_id,
				min_duration
			},
			_ => unreachable!("database constraint violated")
		};
		Ok( Streak {id, interval, begin, end, added, removed, rule} )
	}
}

impl<'db> Formatter<&Streak> for LookupFormatter<'db> {
	fn format(&mut self, buf: &mut Buffer, streak: &Streak) {
		let subject = streak.rule.get_subject(self.nunc).unwrap();
		buf.push(subject)
			.push(": do ")
			.push(streak.rule.required())
			.push(" every ")
			.push(streak.interval)
			.push(", ");

		let (scale, _count) = streak.interval.scale_div();
		let begin = self.format_by(scale, streak.begin);

		match streak.end.map(|p| self.cal.lookup(p)) {
			Some(end) => buf
				.push("from ")
				.push(begin)
				.push(" until ")
				.push(end),
			None => buf
				.push("since ")
				.push(begin)
		};

	}
}


/*
 *	STREAK RULE
 */

impl StreakRule {
	pub fn activity(act: &Activity, min_duration: Span) -> Self {
		Self::Activity {
			activity_id: act.id,
			min_duration
		}
	}
	pub fn category(cat: &Category, min_duration: Span) -> Self {
		Self::Category {
			category_id: cat.id,
			min_duration
		}
	}
	pub fn required(&self) -> Span {
		match self {
			Self::Activity {min_duration, ..} => *min_duration,
			Self::Category {min_duration, ..} => *min_duration
		}
	}

	pub(crate) fn get_subject(
		&self,
		tree: &Nunc)
		-> SqlResult<ActOrCat>
	{
		match *self {
			Self::Activity {activity_id, ..} => tree
				.get_activity(activity_id)
				.map(ActOrCat::Activity),
			Self::Category {category_id, ..} => tree
				.get_category(category_id)
				.map(ActOrCat::Category)
		}
	}

	pub(crate) fn get_query(&self) -> (&'static str, u64, Span) {
		match *self {
			Self::Activity {activity_id, min_duration} => {
				let sql =
					"SELECT \
						SUM( \
							MIN(?3, IFNULL(stop, ?3)) \
							- MAX(?2, start) \
						) \
					FROM entry \
					WHERE activity_id = ?1 \
					AND ( \
						(?2 <= start AND start < ?3) \
						OR (?2 < stop AND stop <= ?3) \
						OR (start < ?2 AND ?3 < stop) \
						OR (start <= ?2 AND stop IS NULL) \
					)  \
					GROUP BY activity_id";
				(sql, activity_id, min_duration)
			},
			Self::Category {category_id, min_duration} => {
				let sql =
					"SELECT \
						SUM( \
							MIN(?3, IFNULL(stop, ?3)) \
							- MAX(?2, start) \
						) \
					FROM entry \
					JOIN activity ON entry.activity_id == activity.id \
					JOIN activity_belongs_to \
						ON activity_belongs_to.activity_id == activity.id \
					JOIN category_belongs_to \
						ON activity_belongs_to.category_id \
						== category_belongs_to.category_id \
					WHERE ( \
						activity_belongs_to.category_id == ?1 \
						OR category_belongs_to.path LIKE '%/' || ?1 || '/%' \
					) \
					AND ( \
						(?2 <= start AND start < ?3) \
						OR (?2 < stop AND stop <= ?3) \
						OR (start < ?2 AND ?3 < stop) \
						OR (start <= ?2 AND stop IS NULL) \
					)";
				(sql, category_id, min_duration)
			}
		}
	}
}