use crate::api::kasl_server::{DayUpload, PauseUpload, TaskUpload};
use crate::db::{
pauses::Pauses,
tasks::Tasks,
workdays::{Workday, Workdays},
};
use crate::libs::config::Config;
use crate::libs::pause::Pause;
use crate::libs::task::{Task, TaskFilter};
use anyhow::{Result, bail};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, TimeZone};
pub fn build_day_upload(date: NaiveDate) -> Result<Option<DayUpload>> {
let Some(workday) = Workdays::new()?.fetch(date)? else {
return Ok(None);
};
let monitor_config = Config::read()?.monitor.unwrap_or_default();
let pauses = Pauses::new()?
.set_min_duration(monitor_config.min_pause_duration)
.get_workday_pauses(&workday)?;
let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
build_from_parts(&workday, &pauses, &tasks).map(Some)
}
pub fn build_from_parts(workday: &Workday, pauses: &[Pause], tasks: &[Task]) -> Result<DayUpload> {
let started_at = with_local_offset(workday.start)?;
let ended_at = workday.end.map(with_local_offset).transpose()?;
let pauses = pauses
.iter()
.map(|pause| {
Ok(PauseUpload {
started_at: with_local_offset(pause.start)?,
ended_at: pause.end.map(with_local_offset).transpose()?,
duration_seconds: pause.duration.map(|duration| duration.num_seconds() as i32),
manual: pause.protected,
reason: None,
})
})
.collect::<Result<Vec<_>>>()?;
let tasks = tasks.iter().map(task_upload).collect::<Result<Vec<_>>>()?;
Ok(DayUpload {
date: workday.date,
started_at,
ended_at,
pauses,
tasks,
tasks_are_complete: true,
})
}
fn task_upload(task: &Task) -> Result<TaskUpload> {
let Some(id) = task.id else {
bail!("task '{}' has no id and cannot be sent", task.name);
};
let timestamp = task
.timestamp
.as_deref()
.ok_or_else(|| anyhow::anyhow!("task '{}' has no timestamp and cannot be sent", task.name))?;
let recorded_at = NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")
.map_err(|error| anyhow::anyhow!("task '{}' has an unreadable timestamp '{}': {}", task.name, timestamp, error))?;
Ok(TaskUpload {
agent_task_id: id,
agent_group_id: task.task_id,
recorded_at: with_local_offset(recorded_at)?,
name: task.name.clone(),
comment: Some(task.comment.clone()).filter(|comment| !comment.trim().is_empty()),
completeness: task.completeness.unwrap_or(0).clamp(0, 100) as i16,
})
}
pub fn with_local_offset(naive: NaiveDateTime) -> Result<DateTime<FixedOffset>> {
with_offset_of(&Local, naive)
}
fn with_offset_of<Tz: TimeZone>(zone: &Tz, naive: NaiveDateTime) -> Result<DateTime<FixedOffset>> {
match zone.from_local_datetime(&naive) {
chrono::LocalResult::Single(local) => Ok(local.fixed_offset()),
chrono::LocalResult::Ambiguous(_, _) => bail!(
"'{}' happened twice on this machine (the clocks went back), so it has no single UTC offset - correct it with `kasl pauses` or `kasl report` before sending",
naive
),
chrono::LocalResult::None => bail!(
"'{}' never happened on this machine (the clocks went forward), so it has no UTC offset - correct it with `kasl pauses` or `kasl report` before sending",
naive
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::libs::task::Task;
use chrono::{Duration, NaiveDate};
fn workday(start: &str, end: Option<&str>) -> Workday {
Workday {
id: 1,
date: NaiveDate::parse_from_str(&start[..10], "%Y-%m-%d").unwrap(),
start: NaiveDateTime::parse_from_str(start, "%Y-%m-%d %H:%M:%S").unwrap(),
end: end.map(|end| NaiveDateTime::parse_from_str(end, "%Y-%m-%d %H:%M:%S").unwrap()),
}
}
fn stored_task(id: i32, name: &str, completeness: Option<i32>) -> Task {
let mut task = Task::new(name, "", completeness);
task.id = Some(id);
task.timestamp = Some("2026-08-31 10:00:00".to_string());
task
}
#[test]
fn every_instant_carries_an_offset() {
let day = build_from_parts(&workday("2026-08-31 09:00:00", Some("2026-08-31 18:00:00")), &[], &[]).unwrap();
let wall_clock = NaiveDateTime::parse_from_str("2026-08-31 09:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
assert_eq!(day.started_at.naive_local(), wall_clock);
assert_eq!(day.started_at, Local.from_local_datetime(&wall_clock).unwrap().fixed_offset());
assert_eq!(day.date, NaiveDate::from_ymd_opt(2026, 8, 31).unwrap());
}
#[test]
fn an_open_day_has_no_end() {
let day = build_from_parts(&workday("2026-08-31 09:00:00", None), &[], &[]).unwrap();
assert!(day.ended_at.is_none());
}
#[test]
fn the_task_set_is_declared_complete() {
let day = build_from_parts(&workday("2026-08-31 09:00:00", None), &[], &[]).unwrap();
assert!(day.tasks_are_complete);
}
#[test]
fn a_task_carries_both_of_its_ids() {
let mut task = stored_task(7, "Write the ingest client", Some(50));
task.task_id = Some(3);
let day = build_from_parts(&workday("2026-08-31 09:00:00", None), &[], &[task]).unwrap();
let uploaded = &day.tasks[0];
assert_eq!(uploaded.agent_task_id, 7, "the row id is what a re-upload matches on");
assert_eq!(uploaded.agent_group_id, Some(3), "the group id ties the same work across days");
assert_eq!(uploaded.completeness, 50);
}
#[test]
fn an_empty_comment_is_sent_as_absent() {
let day = build_from_parts(&workday("2026-08-31 09:00:00", None), &[], &[stored_task(1, "Task", Some(0))]).unwrap();
assert!(day.tasks[0].comment.is_none());
}
#[test]
fn completeness_is_clamped_into_the_range_the_server_accepts() {
let day = build_from_parts(
&workday("2026-08-31 09:00:00", None),
&[],
&[
stored_task(1, "Over", Some(150)),
stored_task(2, "Under", Some(-5)),
stored_task(3, "Unset", None),
],
)
.unwrap();
assert_eq!(day.tasks[0].completeness, 100);
assert_eq!(day.tasks[1].completeness, 0);
assert_eq!(day.tasks[2].completeness, 0, "an unset completeness means not started");
}
#[test]
fn a_pause_keeps_the_duration_kasl_computed() {
let pause = Pause {
id: 1,
start: NaiveDateTime::parse_from_str("2026-08-31 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
end: Some(NaiveDateTime::parse_from_str("2026-08-31 12:30:00", "%Y-%m-%d %H:%M:%S").unwrap()),
duration: Some(Duration::seconds(2400)),
protected: true,
};
let day = build_from_parts(&workday("2026-08-31 09:00:00", None), &[pause], &[]).unwrap();
assert_eq!(day.pauses[0].duration_seconds, Some(2400));
assert!(day.pauses[0].manual, "a protected pause is one the employee entered by hand");
}
#[derive(Debug, Clone, Copy)]
struct ShiftingZone {
before_hours: i32,
after_hours: i32,
}
const SHIFT_AT_UTC: &str = "2026-10-18 04:00:00";
impl ShiftingZone {
fn forward() -> Self {
ShiftingZone {
before_hours: -4,
after_hours: -3,
}
}
fn back() -> Self {
ShiftingZone {
before_hours: -3,
after_hours: -4,
}
}
fn before(&self) -> FixedOffset {
FixedOffset::east_opt(self.before_hours * 3600).unwrap()
}
fn after(&self) -> FixedOffset {
FixedOffset::east_opt(self.after_hours * 3600).unwrap()
}
fn shift_instant() -> NaiveDateTime {
NaiveDateTime::parse_from_str(SHIFT_AT_UTC, "%Y-%m-%d %H:%M:%S").unwrap()
}
}
impl TimeZone for ShiftingZone {
type Offset = FixedOffset;
fn from_offset(offset: &FixedOffset) -> Self {
let hours = offset.local_minus_utc() / 3600;
ShiftingZone {
before_hours: hours,
after_hours: hours,
}
}
fn offset_from_local_date(&self, _local: &NaiveDate) -> chrono::LocalResult<FixedOffset> {
chrono::LocalResult::Single(self.before())
}
fn offset_from_local_datetime(&self, local: &NaiveDateTime) -> chrono::LocalResult<FixedOffset> {
let utc_before = *local - Duration::hours(self.before_hours as i64);
let utc_after = *local - Duration::hours(self.after_hours as i64);
let valid_before = utc_before < Self::shift_instant();
let valid_after = utc_after >= Self::shift_instant();
match (valid_before, valid_after) {
(false, false) => chrono::LocalResult::None,
(true, true) => chrono::LocalResult::Ambiguous(self.before(), self.after()),
(true, false) => chrono::LocalResult::Single(self.before()),
(false, true) => chrono::LocalResult::Single(self.after()),
}
}
fn offset_from_utc_date(&self, _utc: &NaiveDate) -> FixedOffset {
self.before()
}
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> FixedOffset {
if *utc >= Self::shift_instant() { self.after() } else { self.before() }
}
}
#[test]
fn an_hour_that_never_happened_is_refused_rather_than_invented() {
let skipped = NaiveDateTime::parse_from_str("2026-10-18 00:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
let error = with_offset_of(&ShiftingZone::forward(), skipped).unwrap_err().to_string();
assert!(error.contains("never happened"), "unexpected error: {}", error);
assert!(error.contains("2026-10-18 00:30:00"), "the error should name the instant: {}", error);
}
#[test]
fn an_hour_that_happened_twice_is_refused_rather_than_guessed() {
let repeated = NaiveDateTime::parse_from_str("2026-10-18 00:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
let error = with_offset_of(&ShiftingZone::back(), repeated).unwrap_err().to_string();
assert!(error.contains("happened twice"), "unexpected error: {}", error);
assert!(error.contains("2026-10-18 00:30:00"), "the error should name the instant: {}", error);
}
#[test]
fn the_two_halves_of_a_shifting_day_keep_their_own_offsets() {
let zone = ShiftingZone::forward();
let before = NaiveDateTime::parse_from_str("2026-10-17 09:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let after = NaiveDateTime::parse_from_str("2026-10-18 09:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
assert_eq!(with_offset_of(&zone, before).unwrap().offset(), &zone.before());
assert_eq!(with_offset_of(&zone, after).unwrap().offset(), &zone.after());
}
#[test]
fn a_task_without_an_id_is_named_rather_than_sent() {
let mut task = stored_task(1, "Unsaved", Some(0));
task.id = None;
let error = build_from_parts(&workday("2026-08-31 09:00:00", None), &[], &[task]).unwrap_err().to_string();
assert!(error.contains("Unsaved"), "the error should name the task: {}", error);
}
}