zinzen 0.3.0

Algorithm for auto-scheduling time-constrained tasks on a timeline
//! # ZinZen scheduler
//!
//! The ZinZen scheduler is a "calendar as a function".  
//! Input: A calendar start/end datetime, a list of Goals (with duration), and a
//! list of Budgets (with weekly totals and internal periods) that Goals may
//! optionally reference by id.  
//! Output: A calendar that successfully allocates all Goals - or the maximum amount of Goals in that time period.  
//!
// TODO: fix DocTest
// ```
// use scheduler::scheduler;
//
//     let json_input: serde_json::Value = serde_json::json!({
//       "TODO_working_example"
//     });
//     let input: Input = serde_json::from_value(json_input)?;
//     let output = scheduler::run_scheduler(input);
// ```
//!
//! ## Getting Started
//! This project is hosted on [Github](https://github.com/tijlleenders/ZinZen-scheduler). The Docs.rs / Crates.io version is probably (far) behind.  
//! Please submit an issue there if you've found something we need to improve or have a question regarding how things work.
//!
//! For more explanation, see the crate documentation.
//! There are no features to configure.
//!
//!
//!
//! ## Special Considerations
//!
//! We're not at 1.0 major version yet.  
//! Expect breaking changes for every minor (y in 0.x.y) release!
//!
//! ## Contributing
//!
//! Read the standard [Contributor Covenant Code of Conduct](https://github.com/tijlleenders/ZinZen-scheduler/blob/main/CONTRIBUTING.md).  
//! **TL;DR : Be nice.**  
//! We also use the principles of Robert C. Martin's 'Clean Code' - nicely summarized [on this Gist](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29).  
//! If you find documentation missing, this is considered a bug, so please submit a bug report!
//!
//! ## License and legal stuff
//!
//! SPDX-License-Identifier: LicenseRef-COMMONS-1.0
//!
//! See [LICENSE.md](LICENSE.md) for the full license text.
//!
//! ©2020-now ZinZen®
//!
//! This code is licensed under the COMMONS License v1.0. This license does not
//! implicitly grant permission to use the trade names, trademarks, service marks,
//! or product names of the licensor, except as required for reasonable and
//! customary use in describing the origin of the Work and the content of the
//! notice/attribution file.
//!
//! ZinZen® supports an open and collaborative process. Registering the
//! ZinZen® trademark is a tool to protect the ZinZen® identity and the
//! quality perception of the ZinZen® projects.

use crate::models::activity::Activity;
use crate::models::time_grid;
use activity_generator::{
    add_budget_min_day_activities, add_budget_min_week_activities,
    add_budget_top_up_week_activities, add_simple_activities, add_tasks_completed_today,
};
use activity_placer::{place, place_postponed_as_best_effort};
use chrono::{Datelike, Duration, NaiveDateTime};
use models::task::TaskCompletedToday;
use models::{calendar::Calendar, goal::Goal, task::FinalTasks};
use serde_wasm_bindgen::{from_value, to_value};
use services::activity_generator;
use services::activity_placer;
use std::collections::BTreeMap;
use technical::error::SchedulerError;
use technical::input_output::Input;
use wasm_bindgen::prelude::*;

pub mod models;
pub mod services;
/// The data structures
pub mod technical;

#[wasm_bindgen(typescript_custom_section)]
const TS_APPEND_CONTENT: &'static str = r#"
// Kept in sync with `technical::input_output::Input`, `models::goal::Goal`, and `models::goal::Budget`.
type NaiveDateTime = string; // e.g. "2022-01-01T00:00:00"
type Weekday = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun";

interface Slot {
    start: NaiveDateTime;
    end: NaiveDateTime;
}

interface PeriodWindow {
    afterTime: number;   // minutes since midnight [0, 1440) (legacy v1: hours [0, 24))
    beforeTime: number;
}

interface BudgetPeriod {
    id?: string;
    onDays: Weekday[];
    window: PeriodWindow;
    minForPeriod: number;  // minutes within this period across its days (legacy v1: hours)
    maxForPeriod: number;
}

interface Budget {
    id: string;
    title?: string;
    minPerWeek: number;  // weekly total across all periods (minutes; legacy v1: hours)
    maxPerWeek: number;
    periods: BudgetPeriod[];
}

interface Goal {
    id: string;
    title: string;
    start?: NaiveDateTime;
    deadline?: NaiveDateTime;
    minDuration?: number;  // fixed uniform block size (minutes; legacy v1: hours)
    budgetId?: string;     // optional reference into Input.budgets[].id
    notOn?: Slot[];
}

interface Input {
    schemaVersion?: number; // 1 = legacy hours (default), 2 = minutes
    startDate: NaiveDateTime;
    endDate: NaiveDateTime;
    goals: Goal[];
    budgets?: Budget[];
    tasksCompletedToday: { goalid: string; start: NaiveDateTime; deadline: NaiveDateTime }[];
    globalNotOn?: Slot[];
}
"#;

// https://rustwasm.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html
/// The main wasm function to call
#[wasm_bindgen]
pub fn schedule(input: &JsValue) -> Result<JsValue, JsError> {
    console_error_panic_hook::set_once();
    // JsError implements From<Error>, so we can just use `?` on any Error
    let input: Input = from_value(input.clone())?;
    let final_tasks = run_scheduler(&input)?;
    Ok(to_value(&final_tasks)?)
}

pub fn run_scheduler(input: &Input) -> Result<FinalTasks, SchedulerError> {
    let mut goals = input.goals.clone();
    let mut budgets = input.budgets.clone();
    technical::normalize::normalize_goals(&mut goals, input.schema_version)?;
    technical::normalize::normalize_budgets(&mut budgets, input.schema_version)?;
    validate_input(
        input.start_date,
        input.end_date,
        &goals,
        &input.tasks_completed_today,
    )?;

    let start_date = input.start_date;
    let end_date = input.end_date;
    let tasks_completed_today = input.tasks_completed_today.clone();

    let mut calendar = Calendar::new(start_date, end_date);
    let mut activities: Vec<Activity> = vec![];
    let mut goal_map: BTreeMap<String, Goal> = BTreeMap::new(); //Don't use hashmap as that doesn't guarantee ordering - messing up determinacy of tests
    for goal in &goals {
        goal_map.insert(goal.id.clone(), goal.clone());
    }

    calendar.add_budgets_from(&goal_map, &budgets)?;

    crate::log_dbg!(&calendar); //before tasks_completed_today
    add_tasks_completed_today(
        &calendar,
        &goal_map,
        &tasks_completed_today,
        &mut activities,
    );
    place(&mut calendar, &mut activities);

    crate::log_dbg!(&calendar); //before simple
    add_simple_activities(&mut calendar, &goal_map, &mut activities)?;
    add_budget_min_day_activities(&mut calendar, &mut activities);

    place(&mut calendar, &mut activities);

    crate::log_dbg!(&calendar); //before get_budget_min
    add_budget_min_week_activities(&calendar, &mut activities);
    place(&mut calendar, &mut activities);

    crate::log_dbg!(&calendar); //before get_budget_top_up_week
    add_budget_top_up_week_activities(&calendar, &mut activities);
    place(&mut calendar, &mut activities);

    crate::log_dbg!(&calendar); //before BestEffort
    place_postponed_as_best_effort(&mut calendar, &mut activities);

    crate::log_dbg!(&calendar); //final result

    calendar.log_impossible_activities(&activities);
    Ok(calendar.print_new(&activities))
}

/// Reject user-provided datetimes that fall more than one day outside the
/// calendar window before they reach the (panicking) index conversion, turning
/// input errors into a recoverable [`SchedulerError`] (ADR-0002).
fn validate_input(
    start_date: NaiveDateTime,
    end_date: NaiveDateTime,
    goals: &[Goal],
    tasks_completed_today: &[TaskCompletedToday],
) -> Result<(), SchedulerError> {
    let span_minutes = (end_date - start_date).num_minutes();
    if span_minutes <= 0 || span_minutes % time_grid::SLOT_MINUTES != 0 {
        return Err(SchedulerError::CalendarSpanNotAligned {
            span_minutes,
            slot_minutes: time_grid::SLOT_MINUTES as usize,
        });
    }

    let check_aligned = |what: &'static str, dt: NaiveDateTime| -> Result<(), SchedulerError> {
        if !time_grid::is_datetime_aligned(dt) {
            return Err(SchedulerError::DateTimeNotAligned {
                what,
                datetime: dt,
                slot_minutes: time_grid::SLOT_MINUTES as usize,
            });
        }
        Ok(())
    };

    check_aligned("startDate", start_date)?;
    check_aligned("endDate", end_date)?;

    let lower = start_date - Duration::days(1);
    let upper = end_date + Duration::days(1);
    let check = |what: &'static str, dt: NaiveDateTime| -> Result<(), SchedulerError> {
        check_aligned(what, dt)?;
        if dt < lower || dt > upper {
            Err(SchedulerError::DateTimeOutOfBounds {
                what,
                datetime: dt,
                start: start_date,
                end: end_date,
            })
        } else {
            Ok(())
        }
    };

    for task in tasks_completed_today {
        check("tasksCompletedToday.start", task.start)?;
        check("tasksCompletedToday.deadline", task.deadline)?;
    }
    for goal in goals {
        if goal.start.year() != 1970 && goal.start >= lower && goal.start <= upper {
            check("goal.start", goal.start)?;
        }
        if let Some(deadline) = goal.deadline {
            check("goal.deadline", deadline)?;
        }
        if let Some(not_on) = &goal.not_on {
            for slot in not_on {
                check("goal.notOn.start", slot.start)?;
                check("goal.notOn.end", slot.end)?;
            }
        }
    }
    Ok(())
}