Skip to main content

kasl_server/
ingest.rs

1//! The upload endpoints: `POST /api/v1/days` and `/days/batch`.
2//!
3//! An agent sends a day (the workday, its pauses, and the tasks recorded on it)
4//! and the server writes it whole or not at all. After time offline it sends a
5//! stretch of them at once; every day in a batch travels the same path as a
6//! live one, and is written on its own so a day that cannot be accepted does
7//! not hold up the rest (ADR 0005).
8//!
9//! Two rules define the contract, both settled before a line of this was
10//! written:
11//!
12//! * **The agent is the source of truth.** A re-upload overwrites what the
13//!   server holds for that date. The employee edits their day in kasl - fixes
14//!   a task, adds a break they took - and the correction has to land. As a
15//!   consequence the same payload sent twice leaves the same rows, which is
16//!   what makes a retry after a lost connection safe.
17//! * **Timestamps carry an offset, and the day carries its own date.** kasl
18//!   stores bare wall-clock text; sending that as-is would make one team's
19//!   hours incomparable across time zones. See ADR 0003.
20
21use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
22use chrono::{DateTime, FixedOffset, NaiveDate, TimeDelta, Utc};
23use serde::{Deserialize, Serialize};
24use sqlx::{Postgres, Transaction};
25use uuid::Uuid;
26
27use crate::{
28    app::AppState,
29    auth::AuthenticatedAgent,
30    calendar::WorkdayKind,
31    error::ApiError,
32    privacy::{Dropped, Policy, PrivacyLevel},
33    webhooks::{self, DayPayload, Event, EventKind, Webhooks},
34};
35
36/// How recently a day must have ended for its arrival to be news.
37///
38/// A day is announced when it arrives finished for the first time - and an
39/// agent back from a fortnight offline sends a fortnight of them at once. The
40/// channel should hear about yesterday evening, not receive ten messages
41/// about days everybody has long since forgotten.
42const CLOSED_DAY_IS_NEWS_FOR: TimeDelta = TimeDelta::hours(24);
43
44/// One day as the agent recorded it.
45#[derive(Debug, Deserialize)]
46pub struct DayUpload {
47    /// The employee's local calendar date, `YYYY-MM-DD`. Sent explicitly
48    /// rather than derived from `started_at`: which day work belongs to is the
49    /// agent's call, and near midnight the two disagree.
50    pub date: NaiveDate,
51    /// When the day started, with the agent's UTC offset.
52    pub started_at: DateTime<FixedOffset>,
53    /// When it ended; absent while the day is still open.
54    #[serde(default)]
55    pub ended_at: Option<DateTime<FixedOffset>>,
56    #[serde(default)]
57    pub pauses: Vec<PauseUpload>,
58    #[serde(default)]
59    pub tasks: Vec<TaskUpload>,
60    /// Whether `tasks` is everything the agent holds for this date.
61    ///
62    /// When set, a task the server stored on this date and the agent no longer
63    /// sends is one the employee deleted, and it is removed here too. Tasks on
64    /// other dates are untouched - they are matched by id and outlive a single
65    /// day, so wiping by date alone would take yesterday's copy with it.
66    ///
67    /// Defaults to false: an older agent, which cannot know about this flag,
68    /// must never have its silence read as "delete the rest".
69    #[serde(default)]
70    pub tasks_are_complete: bool,
71    /// What kind of day this was: worked, or away on leave, ill, or simply
72    /// not working (`kasl day off`).
73    ///
74    /// Optional, and defaults to `work` for the same reason as the flag above:
75    /// an agent shipped before the field exists must not have its silence read
76    /// as an absence. The norm reads it - a day of leave owes no hours
77    /// (ADR 0017).
78    #[serde(default)]
79    pub kind: WorkdayKind,
80}
81
82#[derive(Debug, Deserialize)]
83pub struct PauseUpload {
84    pub started_at: DateTime<FixedOffset>,
85    #[serde(default)]
86    pub ended_at: Option<DateTime<FixedOffset>>,
87    /// Seconds. The agent merges neighbouring pauses before sending, so this
88    /// is not always `ended_at - started_at` and is taken as given.
89    #[serde(default)]
90    pub duration_seconds: Option<i32>,
91    /// A break the employee entered by hand (the agent's `protected` flag).
92    #[serde(default)]
93    pub manual: bool,
94    #[serde(default)]
95    pub reason: Option<String>,
96}
97
98#[derive(Debug, Deserialize)]
99pub struct TaskUpload {
100    /// The agent's own row id. The key a re-upload matches on, so a corrected
101    /// task updates instead of piling up.
102    pub agent_task_id: i32,
103    /// The agent's `task_id`: the same work carried across several days.
104    /// Defaults to `agent_task_id`, which is what the agent stores for a task
105    /// started today.
106    #[serde(default)]
107    pub agent_group_id: Option<i32>,
108    pub recorded_at: DateTime<FixedOffset>,
109    pub name: String,
110    #[serde(default)]
111    pub comment: Option<String>,
112    /// Percent complete, 0..=100.
113    pub completeness: i16,
114}
115
116/// What the agent gets back: enough to log, and to notice a silent no-op.
117#[derive(Debug, Serialize)]
118pub struct DayAccepted {
119    pub workday_id: Uuid,
120    pub date: NaiveDate,
121    /// Echoed back so an agent can tell a day it marked as leave from one the
122    /// server read as ordinary work - a server too old for the field answers
123    /// without it.
124    pub kind: WorkdayKind,
125    pub pauses: usize,
126    pub tasks: usize,
127    /// Tasks dropped because the agent declared its set authoritative. Zero on
128    /// the common upload; a non-zero count is worth noticing in a log.
129    pub deleted_tasks: u64,
130    /// The privacy level that applied. Always reported, even at `full`: an
131    /// agent should be able to tell an installation that keeps everything from
132    /// one whose policy it has not read yet.
133    pub privacy_level: PrivacyLevel,
134    /// What that level discarded. Absent from the response when it discarded
135    /// nothing, which is the common case (ADR 0011).
136    #[serde(skip_serializing_if = "Dropped::is_empty")]
137    pub discarded: Dropped,
138}
139
140/// A stretch of days at once - what an agent sends after time offline.
141#[derive(Debug, Deserialize)]
142pub struct BatchUpload {
143    pub days: Vec<DayUpload>,
144}
145
146/// What came of a batch. Counts first: a caller that reads only the status sees
147/// `200` even when a day was refused, so the summary has to be impossible to
148/// miss in the body.
149#[derive(Debug, Serialize)]
150pub struct BatchResult {
151    pub accepted: usize,
152    pub rejected: usize,
153    pub results: Vec<DayResult>,
154}
155
156/// One day's fate, in the order the days were sent.
157#[derive(Debug, Serialize)]
158#[serde(tag = "status", rename_all = "lowercase")]
159pub enum DayResult {
160    Accepted {
161        #[serde(flatten)]
162        day: DayAccepted,
163    },
164    /// The date is echoed even here: it is how the agent knows which of its
165    /// pending days to keep, and a day can be refused before anything else
166    /// about it is known to be usable.
167    Rejected { date: NaiveDate, error: String },
168}
169
170/// Accepts one day from an authenticated agent.
171pub async fn upload_day(State(state): State<AppState>, agent: AuthenticatedAgent, Json(day): Json<DayUpload>) -> Result<impl IntoResponse, ApiError> {
172    let policy = Policy::load(&state.pool).await?;
173    let accepted = store_day(&state.pool, agent, &day, policy, &state.webhooks, Utc::now()).await?;
174    Ok((StatusCode::OK, Json(accepted)))
175}
176
177/// Accepts a backlog of days, each written on its own.
178///
179/// One bad day does not sink the batch. An agent holding a day the server will
180/// never accept would otherwise be unable to deliver any of its backlog, and
181/// would retry the same doomed request forever (ADR 0005).
182pub async fn upload_batch(State(state): State<AppState>, agent: AuthenticatedAgent, Json(batch): Json<BatchUpload>) -> Result<impl IntoResponse, ApiError> {
183    if batch.days.len() > state.max_batch_days {
184        return Err(ApiError::new(
185            StatusCode::PAYLOAD_TOO_LARGE,
186            format!("a batch carries at most {} days; split the backlog", state.max_batch_days),
187        ));
188    }
189
190    // Once for the batch, not once per day in it: thirty days of backfill
191    // are one policy, and reading it thirty times would only add ways for the
192    // days in one request to disagree with each other.
193    let policy = Policy::load(&state.pool).await?;
194    let now = Utc::now();
195
196    let mut results = Vec::with_capacity(batch.days.len());
197    let mut accepted = 0;
198    let mut rejected = 0;
199
200    for day in &batch.days {
201        match store_day(&state.pool, agent, day, policy, &state.webhooks, now).await {
202            Ok(stored) => {
203                accepted += 1;
204                results.push(DayResult::Accepted { day: stored });
205            }
206            // A day the server itself failed on aborts the batch: the agent
207            // must retry it, and reporting a database outage as "this day is
208            // rejected" would tell it to give up instead.
209            Err(error) if error.status().is_server_error() => return Err(error),
210            Err(error) => {
211                rejected += 1;
212                results.push(DayResult::Rejected {
213                    date: day.date,
214                    error: error.to_string(),
215                });
216            }
217        }
218    }
219
220    tracing::info!(user_id = %agent.user_id, agent_id = %agent.agent_id, accepted, rejected, "accepted a batch");
221
222    Ok((StatusCode::OK, Json(BatchResult { accepted, rejected, results })))
223}
224
225/// Writes one day, whole or not at all.
226///
227/// Shared by the single-day route and the batch one so a backfilled day is
228/// stored by exactly the same code as a live one.
229async fn store_day(
230    pool: &sqlx::PgPool,
231    agent: AuthenticatedAgent,
232    day: &DayUpload,
233    policy: Policy,
234    webhooks: &Webhooks,
235    now: DateTime<Utc>,
236) -> Result<DayAccepted, ApiError> {
237    validate(day)?;
238
239    // From what the agent sent, before the level filters anything: the hours
240    // of a day are the same fact at every level, and a level that stores no
241    // pauses must not make a channel read a day as unpaused.
242    let paused_seconds = pause_totals(&day.pauses).seconds;
243
244    // Before the transaction, deliberately: what the level excludes is never
245    // handed to a statement, so it cannot be written and then filtered on the
246    // way out. The promise is about the disk (ADR 0011).
247    let level = policy.level();
248    let (day, discarded, pause_totals) = filter(day, level);
249    let day = &day;
250
251    // All of it or none: a day whose pauses landed but whose tasks did not
252    // would show up on a dashboard as real, and nobody would know to re-send.
253    let mut tx = pool.begin().await?;
254
255    // Whether this upload is the one that closes the day. Read under a lock,
256    // so two uploads of the same day racing each other cannot both see it
257    // open; the event's id is derived from the day and its end besides, so
258    // even a close queued twice is delivered once.
259    let was_closed: Option<bool> = sqlx::query_scalar("SELECT ended_at IS NOT NULL FROM workdays WHERE user_id = $1 AND date = $2 FOR UPDATE")
260        .bind(agent.user_id)
261        .bind(day.date)
262        .fetch_optional(&mut *tx)
263        .await?;
264
265    let workday_id = upsert_workday(&mut tx, agent.user_id, day, pause_totals).await?;
266    replace_pauses(&mut tx, workday_id, &day.pauses).await?;
267    let tasks = upsert_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?;
268    // A level that stores no tasks clears the date's, flag or no flag. Without
269    // this, a task written while the policy was wider survives a re-upload
270    // under a narrower one, and the server keeps a name and a comment the
271    // policy says it does not hold - found by driving the real thing, because
272    // the pauses next to it are replaced wholesale and looked fine.
273    let deleted_tasks = if day.tasks_are_complete || !level.keeps_tasks() {
274        delete_missing_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?
275    } else {
276        0
277    };
278
279    if let Some(ended_at) = day.ended_at.map(|at| at.with_timezone(&Utc))
280        && was_closed != Some(true)
281        && now - ended_at <= CLOSED_DAY_IS_NEWS_FOR
282        && webhooks.anyone_hears(EventKind::DayClosed)
283    {
284        let started_at = day.started_at.with_timezone(&Utc);
285        let closed = DayPayload {
286            date: day.date,
287            kind: day.kind,
288            started_at,
289            ended_at,
290            worked_seconds: ((ended_at - started_at).num_seconds() - i64::from(paused_seconds)).max(0),
291        };
292        let person = webhooks::person(&mut tx, agent.user_id).await?;
293        webhooks::enqueue(&mut tx, webhooks, &Event::day_closed(workday_id, closed, person, webhooks, now)).await?;
294    }
295
296    tx.commit().await?;
297
298    // The agent, not just the person: several machines report for one employee
299    // and "which one sent this" is the first question when a day looks wrong.
300    tracing::info!(%workday_id, user_id = %agent.user_id, agent_id = %agent.agent_id, date = %day.date, pauses = day.pauses.len(), tasks, deleted_tasks, "accepted a day");
301
302    Ok(DayAccepted {
303        workday_id,
304        date: day.date,
305        kind: day.kind,
306        pauses: day.pauses.len(),
307        tasks,
308        deleted_tasks,
309        privacy_level: level,
310        discarded,
311    })
312}
313
314/// Applies the installation's privacy level to a day before anything is
315/// written.
316///
317/// Returns the day as it will be stored, what was left out, and - for a level
318/// that does not keep pauses one by one - what they came to. The totals are
319/// computed here, from what the agent sent, because after filtering the rows
320/// are gone and the day could no longer describe itself.
321///
322/// The day is rebuilt rather than mutated in place because the caller holds a
323/// borrowed upload that a batch will reuse: filtering must not change what the
324/// next day in the request sees.
325fn filter(day: &DayUpload, level: PrivacyLevel) -> (DayUpload, Dropped, Option<PauseTotals>) {
326    let mut discarded = Dropped::default();
327
328    let pauses = if level.keeps_pause_times() {
329        day.pauses
330            .iter()
331            .map(|pause| PauseUpload {
332                started_at: pause.started_at,
333                ended_at: pause.ended_at,
334                duration_seconds: pause.duration_seconds,
335                manual: pause.manual,
336                reason: match (&pause.reason, level.keeps_free_text()) {
337                    (Some(reason), false) if !reason.is_empty() => {
338                        discarded.free_text += 1;
339                        None
340                    }
341                    (reason, true) => reason.clone(),
342                    _ => None,
343                },
344            })
345            .collect()
346    } else {
347        // Not stored one by one - the day carries the count and the total
348        // instead, so its hours still add up (see `paused_count` in the
349        // schema). An empty list here means "no rows", not "no interruptions".
350        discarded.pauses = day.pauses.len();
351        Vec::new()
352    };
353
354    let tasks = if level.keeps_tasks() {
355        day.tasks
356            .iter()
357            .map(|task| TaskUpload {
358                agent_task_id: task.agent_task_id,
359                agent_group_id: task.agent_group_id,
360                recorded_at: task.recorded_at,
361                name: task.name.clone(),
362                comment: match (&task.comment, level.keeps_free_text()) {
363                    (Some(comment), false) if !comment.is_empty() => {
364                        discarded.free_text += 1;
365                        None
366                    }
367                    (comment, true) => comment.clone(),
368                    _ => None,
369                },
370                completeness: task.completeness,
371            })
372            .collect()
373    } else {
374        discarded.tasks = day.tasks.len();
375        Vec::new()
376    };
377
378    // `tasks_are_complete` is carried through even where no tasks are stored:
379    // it is how a level that stops keeping tasks clears the ones an earlier,
380    // wider level left behind. Tightening the policy does not erase history on
381    // its own, but a day the agent re-sends is stored under the policy in
382    // force now.
383    let filtered = DayUpload {
384        date: day.date,
385        started_at: day.started_at,
386        ended_at: day.ended_at,
387        pauses,
388        tasks,
389        tasks_are_complete: day.tasks_are_complete,
390        // Not a privacy level's business: which kind of day it was is a fact
391        // about the calendar, not about what the employee did at the keyboard,
392        // and a day of leave stored as a worked day would make the norm lie at
393        // every level.
394        kind: day.kind,
395    };
396
397    let totals = (!level.keeps_pause_times()).then(|| pause_totals(&day.pauses));
398
399    (filtered, discarded, totals)
400}
401
402/// What a day's pauses came to: how many, and how long in total.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404struct PauseTotals {
405    count: i32,
406    seconds: i32,
407}
408
409/// What a day's pauses come to, for the levels that do not store them one by
410/// one.
411///
412/// Counted from what the agent sent, before filtering drops the rows: the
413/// summary has to describe the day that happened, not the empty list left
414/// after the policy is applied.
415fn pause_totals(pauses: &[PauseUpload]) -> PauseTotals {
416    let count = pauses.len() as i32;
417    let seconds = pauses
418        .iter()
419        .map(|pause| {
420            pause.duration_seconds.unwrap_or_else(|| {
421                // A pause the agent did not measure: fall back to the interval
422                // it reported, and to zero for one still running, which has no
423                // duration yet by definition.
424                pause
425                    .ended_at
426                    .map(|ended| (ended - pause.started_at).num_seconds().clamp(0, i32::MAX as i64) as i32)
427                    .unwrap_or(0)
428            })
429        })
430        .fold(0i32, |total, seconds| total.saturating_add(seconds));
431
432    PauseTotals { count, seconds }
433}
434
435/// Rejects payloads the schema would refuse anyway, with a message that says
436/// which field is wrong - a constraint violation surfaces as a 500 and tells
437/// the agent nothing it can act on.
438fn validate(day: &DayUpload) -> Result<(), ApiError> {
439    if let Some(ended_at) = day.ended_at
440        && ended_at < day.started_at
441    {
442        return Err(ApiError::bad_request("ended_at is before started_at"));
443    }
444
445    for (index, pause) in day.pauses.iter().enumerate() {
446        if let Some(ended_at) = pause.ended_at
447            && ended_at < pause.started_at
448        {
449            return Err(ApiError::bad_request(format!("pauses[{index}]: ended_at is before started_at")));
450        }
451        if pause.duration_seconds.is_some_and(|seconds| seconds < 0) {
452            return Err(ApiError::bad_request(format!("pauses[{index}]: duration_seconds is negative")));
453        }
454    }
455
456    for (index, task) in day.tasks.iter().enumerate() {
457        if !(0..=100).contains(&task.completeness) {
458            return Err(ApiError::bad_request(format!("tasks[{index}]: completeness must be between 0 and 100")));
459        }
460        if task.name.trim().is_empty() {
461            return Err(ApiError::bad_request(format!("tasks[{index}]: name is empty")));
462        }
463    }
464
465    Ok(())
466}
467
468/// Writes the day, or corrects the one already stored for that date.
469///
470/// `pause_totals` is set only under a level that does not store pauses one by
471/// one. Without it such a day would claim uninterrupted work - a more
472/// flattering picture than the truth, and a false one.
473async fn upsert_workday(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, day: &DayUpload, pause_totals: Option<PauseTotals>) -> Result<Uuid, ApiError> {
474    let workday_id: Uuid = sqlx::query_scalar(
475        "INSERT INTO workdays (user_id, date, started_at, ended_at, paused_count, paused_seconds, kind) VALUES ($1, $2, $3, $4, $5, $6, $7)
476         ON CONFLICT (user_id, date) DO UPDATE SET
477             started_at = EXCLUDED.started_at,
478             ended_at = EXCLUDED.ended_at,
479             paused_count = EXCLUDED.paused_count,
480             paused_seconds = EXCLUDED.paused_seconds,
481             kind = EXCLUDED.kind
482         RETURNING id",
483    )
484    .bind(user_id)
485    .bind(day.date)
486    .bind(day.started_at.with_timezone(&Utc))
487    .bind(day.ended_at.map(|at| at.with_timezone(&Utc)))
488    .bind(pause_totals.map(|totals| totals.count))
489    .bind(pause_totals.map(|totals| totals.seconds))
490    .bind(day.kind)
491    .fetch_one(&mut **tx)
492    .await?;
493
494    Ok(workday_id)
495}
496
497/// Replaces the day's pauses wholesale.
498///
499/// Pauses have no agent-side identity to match on - the agent splits and
500/// merges them as activity comes in - so the day's set is what was sent, and
501/// a pause the employee deleted disappears here too.
502async fn replace_pauses(tx: &mut Transaction<'_, Postgres>, workday_id: Uuid, pauses: &[PauseUpload]) -> Result<(), ApiError> {
503    sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
504        .bind(workday_id)
505        .execute(&mut **tx)
506        .await?;
507
508    for pause in pauses {
509        sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
510            .bind(workday_id)
511            .bind(pause.started_at.with_timezone(&Utc))
512            .bind(pause.ended_at.map(|at| at.with_timezone(&Utc)))
513            .bind(pause.duration_seconds)
514            .bind(pause.manual)
515            .bind(pause.reason.as_deref())
516            .execute(&mut **tx)
517            .await?;
518    }
519
520    Ok(())
521}
522
523/// Writes the day's tasks, correcting any the agent has sent before.
524///
525/// Tasks do carry an agent-side id, so they are matched rather than replaced:
526/// the same task may appear on several days, and wiping by date would take
527/// yesterday's copy with it.
528async fn upsert_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<usize, ApiError> {
529    for task in tasks {
530        sqlx::query(
531            "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
532             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
533             ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
534                 agent_group_id = EXCLUDED.agent_group_id,
535                 date = EXCLUDED.date,
536                 recorded_at = EXCLUDED.recorded_at,
537                 name = EXCLUDED.name,
538                 comment = EXCLUDED.comment,
539                 completeness = EXCLUDED.completeness",
540        )
541        .bind(user_id)
542        .bind(task.agent_task_id)
543        .bind(task.agent_group_id.unwrap_or(task.agent_task_id))
544        .bind(date)
545        .bind(task.recorded_at.with_timezone(&Utc))
546        .bind(task.name.trim())
547        .bind(task.comment.as_deref())
548        .bind(task.completeness)
549        .execute(&mut **tx)
550        .await?;
551    }
552
553    Ok(tasks.len())
554}
555
556/// Removes the date's tasks the agent did not send.
557///
558/// Only reached when the agent marked its list authoritative. Scoped to the
559/// one date on purpose: a task carried across several days keeps its rows on
560/// the others, and an agent backfilling Monday cannot erase Friday.
561async fn delete_missing_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<u64, ApiError> {
562    let kept: Vec<i32> = tasks.iter().map(|task| task.agent_task_id).collect();
563
564    let deleted = sqlx::query("DELETE FROM tasks WHERE user_id = $1 AND date = $2 AND agent_task_id <> ALL($3)")
565        .bind(user_id)
566        .bind(date)
567        .bind(&kept)
568        .execute(&mut **tx)
569        .await?
570        .rows_affected();
571
572    Ok(deleted)
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    fn day_json(patch: serde_json::Value) -> DayUpload {
580        let mut value = serde_json::json!({
581            "date": "2026-08-14",
582            "started_at": "2026-08-14T09:00:00-03:00",
583            "pauses": [],
584            "tasks": [],
585        });
586        let (serde_json::Value::Object(base), serde_json::Value::Object(patch)) = (&mut value, patch) else {
587            panic!("both must be objects");
588        };
589        base.extend(patch);
590        serde_json::from_value(value).expect("the fixture should deserialize")
591    }
592
593    #[test]
594    fn an_offset_is_required_on_every_instant() {
595        // The whole point of the contract: bare wall-clock time, which is what
596        // kasl stores locally, must not parse.
597        let bare = serde_json::json!({
598            "date": "2026-08-14",
599            "started_at": "2026-08-14T09:00:00",
600        });
601        assert!(
602            serde_json::from_value::<DayUpload>(bare).is_err(),
603            "an instant without an offset must be rejected"
604        );
605    }
606
607    #[test]
608    fn the_offset_is_preserved_as_an_instant() {
609        let day = day_json(serde_json::json!({ "started_at": "2026-08-14T09:00:00-03:00" }));
610        assert_eq!(day.started_at.with_timezone(&Utc).to_rfc3339(), "2026-08-14T12:00:00+00:00");
611    }
612
613    #[test]
614    fn a_day_may_still_be_open() {
615        let day = day_json(serde_json::json!({}));
616        assert!(day.ended_at.is_none(), "a missing ended_at means the day is still running");
617        validate(&day).expect("an open day is valid");
618    }
619
620    #[test]
621    fn a_day_cannot_end_before_it_starts() {
622        let day = day_json(serde_json::json!({ "ended_at": "2026-08-14T08:00:00-03:00" }));
623        let error = validate(&day).expect_err("a backwards day must be refused");
624        assert_eq!(error.to_string(), "ended_at is before started_at");
625    }
626
627    #[test]
628    fn impossible_pauses_and_tasks_are_named_in_the_error() {
629        let day = day_json(serde_json::json!({
630            "pauses": [
631                {"started_at": "2026-08-14T10:00:00-03:00", "ended_at": "2026-08-14T10:20:00-03:00", "duration_seconds": 1200},
632                {"started_at": "2026-08-14T12:00:00-03:00", "duration_seconds": -1},
633            ],
634        }));
635        let error = validate(&day).expect_err("a negative duration must be refused");
636        assert!(
637            error.to_string().contains("pauses[1]"),
638            "the message should point at the offending element: {error}"
639        );
640
641        let day = day_json(serde_json::json!({
642            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Ship it", "completeness": 101}],
643        }));
644        let error = validate(&day).expect_err("completeness above 100 must be refused");
645        assert!(
646            error.to_string().contains("tasks[0]"),
647            "the message should point at the offending element: {error}"
648        );
649    }
650
651    #[test]
652    fn a_task_group_defaults_to_the_task_itself() {
653        let day = day_json(serde_json::json!({
654            "tasks": [{"agent_task_id": 7, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Write the ingest", "completeness": 60}],
655        }));
656        let task = &day.tasks[0];
657        assert_eq!(task.agent_group_id, None, "an absent group is absent on the wire");
658        assert_eq!(task.agent_group_id.unwrap_or(task.agent_task_id), 7, "and resolves to the task itself");
659    }
660
661    #[test]
662    fn an_agent_that_says_nothing_deletes_nothing() {
663        // The compatibility hinge: agents shipped before this flag existed send
664        // whatever tasks they have, and their silence must not be read as
665        // "delete everything else on that date".
666        let day = day_json(serde_json::json!({}));
667        assert!(!day.tasks_are_complete, "the authoritative set must be opt-in");
668
669        let day = day_json(serde_json::json!({ "tasks_are_complete": true }));
670        assert!(day.tasks_are_complete, "and an agent that opts in is heard");
671    }
672
673    #[test]
674    fn a_day_without_a_kind_is_a_worked_day() {
675        // The compatibility hinge for the second optional field this contract
676        // has gained. Every kasl shipped before v1.35 sends no `kind`, and
677        // reading that silence as anything but "worked" would put the whole
678        // installed base on permanent leave.
679        let day = day_json(serde_json::json!({}));
680        assert_eq!(day.kind, WorkdayKind::Work);
681
682        let day = day_json(serde_json::json!({ "kind": "vacation" }));
683        assert_eq!(day.kind, WorkdayKind::Vacation, "and an agent that says so is heard");
684    }
685
686    #[test]
687    fn a_nameless_task_is_refused() {
688        let day = day_json(serde_json::json!({
689            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "   ", "completeness": 50}],
690        }));
691        assert!(validate(&day).is_err(), "a task with a blank name carries no information");
692    }
693}