nunc 0.2.0

WIP: time tracking application
Documentation
use rusqlite::{
	OptionalExtension,
	Result as SqlResult
};
use greg::{
	Point,
	Span,
	Scale,
	Calendar,
	Utc,
	calendar::zone::Shift
};

use crate::cache::ActivityAffects;
use crate::data::{
	Activity,
	Category,
	Streak,
	StreakRule
};

use super::{
	Tree,
	Zone,
	Database
};

pub trait Plan: Database {
	fn get_streak(&self, id: u64) -> SqlResult<Streak> {
		self.db().query_row(
			"SELECT \
				id, \
				interval, \
				start, \
				stop, \
				added, \
				removed, \
				rule_type, \
				activity_id, \
				min_duration, \
				category_id \
			FROM streak \
			WHERE id = ?1",
			[id],
			|row| row.try_into()
		)
	}

	fn get_active_activity_streak(&self, when: Point, activity: &Activity)
		-> SqlResult<Option<Streak>>
	{
		self.db().query_row(
			"SELECT \
				id, \
				interval, \
				start, \
				stop, \
				added, \
				removed, \
				rule_type, \
				activity_id, \
				min_duration, \
				category_id \
			FROM streak \
			WHERE activity_id = ?1 \
			AND ( \
				(stop IS NULL AND ?2 >= start) \
				OR ?2 BETWEEN start AND stop \
			) \
			ORDER BY start",
			(activity.id, when.timestamp),
			|row| row.try_into()
		).optional()
	}

	fn get_active_category_streak(&self, when: Point, category: &Category)
		-> SqlResult<Option<Streak>>
	{
		self.db().query_row(
			"SELECT \
				id, \
				interval, \
				start, \
				stop, \
				added, \
				removed, \
				rule_type, \
				activity_id, \
				min_duration, \
				category_id \
			FROM streak \
			WHERE category_id = ?1 \
			AND ( \
				(stop IS NULL AND ?2 >= start) \
				OR ?2 BETWEEN start AND stop \
			) \
			ORDER BY start",
			(category.id, when.timestamp),
			|row| row.try_into()
		).optional()
	}

	fn begin(
		&self,
		rule: StreakRule,
		begin: Point,
		interval: Span)
		-> SqlResult<Streak>
	{
		let begin = calculate_streak_begin(self, begin, interval)?;

		let tx = self.db().unchecked_transaction()?;

		let mut stmt = self.db().prepare(
			"INSERT INTO streak_definition (interval, start, added) \
			VALUES (?1, ?2, ?3)"
		)?;
		let added = Point::now();
		let id = stmt.insert((interval, begin, added))? as u64;

		match &rule {
			StreakRule::Activity {activity_id, min_duration} => {
				let mut stmt = self.db().prepare(
					"INSERT INTO activity_streak_rule VALUES (?1, ?2, ?3)"
				)?;
				stmt.insert((id, activity_id, min_duration))?;
			}
			StreakRule::Category {category_id, min_duration} => {
				let mut stmt = self.db().prepare(
					"INSERT INTO category_streak_rule VALUES (?1, ?2, ?3)"
				)?;
				stmt.insert((id, category_id, min_duration))?;
			}
		}

		tx.commit().map(|()| Streak {
			id, interval, begin, end: None, added, removed: None, rule
		})
	}

	fn end(&self, streak: &Streak, end: Point) -> SqlResult<bool> {
		self.cache().execute(
			"UPDATE streakdamage \
			SET damage = MIN(damage, ?2) \
			WHERE streak_id = ?1",
			&(streak.id, end.timestamp)
		)?;
		self.db().execute(
			"UPDATE streak_definition \
			SET stop = ?2, removed = ?3 \
			WHERE id = ?1",
			(streak.id, end, Point::now())
		).map(|i| i == 1)
	}

	fn list_streaks(&self) -> SqlResult<Vec<Streak>> {
		let mut streaks = Vec::new();
		let mut stmt = self.db().prepare(
			"SELECT \
				id, \
				interval, \
				start, \
				stop, \
				added, \
				removed, \
				rule_type, \
				activity_id, \
				min_duration, \
				category_id \
			FROM streak \
			ORDER BY start"
		)?;
		let mut rows = stmt.query([])?;

		while let Some(row) = rows.next()? {
			streaks.push(row.try_into()?);
		}

		Ok(streaks)
	}
	fn list_active_streaks(&self) -> SqlResult<Vec<Streak>> {
		let mut streaks = Vec::new();
		let mut stmt = self.db().prepare(
			"SELECT \
				id, \
				interval, \
				start, \
				stop, \
				added, \
				removed, \
				rule_type, \
				activity_id, \
				min_duration, \
				category_id \
			FROM streak \
			WHERE (stop IS NULL AND ?1 >= start) \
			OR ?1 BETWEEN start AND stop \
			ORDER BY start"
		)?;
		let mut rows = stmt.query([Point::now()])?;

		while let Some(row) = rows.next()? {
			streaks.push(row.try_into()?);
		}

		Ok(streaks)
	}

	fn set_activity_affects(&self, streak: &Streak) -> SqlResult<()> {
		match streak.rule {
			StreakRule::Activity { activity_id, .. } => {
				let affects = ActivityAffects {
					activity_id,
					streak_id: streak.id
				};
				self.cache().upsert(&affects)?;
			},
			StreakRule::Category { category_id, .. } => {
				let cat = self.get_category(category_id)?;
				let acts = self.get_activities_under(&cat)?;
				let mut stmt = self.cache().prepare(
					"INSERT OR IGNORE INTO activityaffects VALUES (?1, ?2)",
				)?;
				for act in acts {
					stmt.execute((act.id, streak.id))?;
				}
			}
		}
		Ok(())
	}
}

impl<Db: Database + ?Sized> Plan for Db {}


/// Calculate the "true begin" point, so that, e.g. daily streaks always start midnight
fn calculate_streak_begin<Db: Database + ?Sized>(
	db: &Db,
	begin: Point,
	interval: Span)
	-> SqlResult<Point>
{
	let (scale, _count) = interval.scale_div();
	assert!(
		scale >= Scale::Days,
		"streak intervals shorter than a day are currently not supported"
	);
	let calendar = db.load_calendar()?;
	let begin_utc = calendar.0.apply(begin);

	Ok(Calendar(Utc).date_floor(scale, begin_utc))
}

#[test]
fn empty() -> SqlResult<()> {
	let nunc = crate::Nunc::testing().unwrap();

	assert!(nunc.list_streaks()?.is_empty());
	Ok(())
}

#[test]
fn begin_end() -> SqlResult<()> {
	let interval = Span::parse("1d");
	let duration = Span::parse("10m");

	let begin = Point::now() - duration * 2;
	let end = Point::now() - duration;

	let activity_rule = StreakRule::Activity {
		activity_id: 1,
		min_duration: duration
	};

	let category_rule = StreakRule::Category {
		category_id: 1,
		min_duration: duration
	};

	for rule in [activity_rule, category_rule] {
		let nunc = crate::Nunc::testing().unwrap();
		crate::tests::add_example_tree(&nunc)?;

		let added = Point::now();
		let streak = nunc.begin(rule, begin, interval)?;

		assert_eq!(nunc.list_streaks()?[0], streak);
		assert_eq!(nunc.list_active_streaks()?[0], streak);
		dbg!(&streak);
		// End streak
		let removed = Point::now();
		dbg!(end);
		assert!(nunc.end(&streak, end)?);

		let stopped = nunc.list_streaks()?.pop().unwrap();
		assert_ne!(stopped, streak);
		assert!(nunc.list_active_streaks()?.is_empty());

		assert_eq!(stopped.end, Some(end));
		assert_eq!(stopped.removed, Some(removed));
		assert_eq!(stopped.added, added);
	}

	Ok(())
}

#[test]
fn activity_affects() -> SqlResult<()> {
	let nunc = crate::Nunc::testing().unwrap();
	crate::tests::add_example_tree(&nunc)?;
	let run_id = nunc.find_activity("Run")?.unwrap().id;
	let run = StreakRule::Activity {
		activity_id: run_id,
		min_duration: Span::parse("10m")
	};
	let run_streak = nunc.begin(run, Point::now(), Span::DAY)?;

	// activity streak

	assert!(nunc.cache().get_all::<ActivityAffects>()?.is_empty());
	nunc.set_activity_affects(&run_streak)?;
	let affects = nunc.cache().get_all::<ActivityAffects>()?;
	assert_eq!(affects.len(), 1);
	assert_eq!(
		affects[0],
		ActivityAffects {streak_id: run_streak.id, activity_id: run_id}
	);

	// category streak

	let beta_id = nunc.find_category("Beta")?.unwrap().id;
	let beta = StreakRule::Category {
		category_id: beta_id,
		min_duration: Span::parse("10m")
	};
	let beta_streak = nunc.begin(beta, Point::now(), Span::DAY)?;
	nunc.set_activity_affects(&beta_streak)?;

	let prog_id = nunc.find_activity("Programming")?.unwrap().id;
	let fit_id = nunc.find_activity("Fitness")?.unwrap().id;
	let all_affects = nunc.cache().get_all::<ActivityAffects>()?;
	assert_eq!(all_affects.len(), 3);
	let affects = all_affects
		.into_iter()
		.filter(|a| a.streak_id == beta_streak.id)
		.collect::<Vec<_>>();
	assert_eq!(affects.len(), 2);
	assert!(affects.iter().any(|a| a.activity_id == prog_id));
	assert!(affects.iter().any(|a| a.activity_id == fit_id));

	// category streak overlapping first activity streak

	let alpha_id = nunc.find_category("Alpha")?.unwrap().id;
	let alpha = StreakRule::Category {
		category_id: alpha_id,
		min_duration: Span::parse("10m")
	};
	let alpha_streak = nunc.begin(alpha, Point::now(), Span::DAY)?;
	nunc.set_activity_affects(&alpha_streak)?;

	let russian_id = nunc.find_activity("Russian")?.unwrap().id;
	let chess_id = nunc.find_activity("Chess")?.unwrap().id;
	let all_affects = nunc.cache().get_all::<ActivityAffects>()?;
	assert_eq!(all_affects.len(), 6);
	let affects = all_affects
		.into_iter()
		.filter(|a| a.streak_id == alpha_streak.id)
		.collect::<Vec<_>>();
	assert_eq!(affects.len(), 3);
	assert!(affects.iter().any(|a| a.activity_id == run_id));
	assert!(affects.iter().any(|a| a.activity_id == russian_id));
	assert!(affects.iter().any(|a| a.activity_id == chess_id));

	let run_affects = nunc.cache().get_all::<ActivityAffects>()?
		.into_iter()
		.filter(|a| a.activity_id == run_id)
		.collect::<Vec<_>>();
	assert_eq!(run_affects.len(), 2);
	assert!(run_affects.iter().any(|a| a.streak_id == run_streak.id));
	assert!(run_affects.iter().any(|a| a.streak_id == alpha_streak.id));

	Ok(())
}