Add exports: timesheet copy presets, clipboard and report files
- timesheet: copy presets (total H:MM, decimal hours, segments, in/out/break as tab-separated cells, custom template) with optional rounding to a step. - clipboard: system clipboard (Windows, X11, Wayland), falling back to the terminal's OSC 52, or a custom command like wl-copy. - csv, json, markdown: report files for a range of dates. CSV uses ';' when the decimal separator is ','; the Markdown report has a summary and one padded table per week. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a646337339
commit
97f834a299
7 files changed
+673
No files matched your search
@@ -0,0 +1,117 @@
|
||||
//! Copying text to the clipboard, in whichever way works where you are.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// How skyshift copies text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ClipboardMode {
|
||||
/// The system clipboard, or the terminal's if that fails.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Only the system clipboard (Windows, or X11/Wayland on Linux).
|
||||
System,
|
||||
/// Ask the terminal to copy, with the OSC 52 escape code. Works over SSH
|
||||
/// in terminals that support it (Windows Terminal, kitty, WezTerm, foot,
|
||||
/// Alacritty...).
|
||||
Terminal,
|
||||
/// Run a command that reads the text on its input, like `wl-copy` or
|
||||
/// `xclip -selection clipboard`.
|
||||
Command,
|
||||
}
|
||||
|
||||
pub struct Clipboard {
|
||||
mode: ClipboardMode,
|
||||
command: String,
|
||||
/// Kept for the whole session: on Linux, the app that copied the text is
|
||||
/// the one that hands it over when you paste, so it must stay alive.
|
||||
system: Option<arboard::Clipboard>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn new(mode: ClipboardMode, command: String) -> Clipboard {
|
||||
let wants_system = matches!(mode, ClipboardMode::Auto | ClipboardMode::System);
|
||||
let system = if wants_system { arboard::Clipboard::new().ok() } else { None };
|
||||
Clipboard { mode, command, system }
|
||||
}
|
||||
|
||||
/// Copies `text`. On success, says where it went (for the status line).
|
||||
pub fn copy(&mut self, text: &str) -> Result<&'static str, String> {
|
||||
match self.mode {
|
||||
ClipboardMode::System => self.copy_to_system(text).map(|()| "clipboard"),
|
||||
ClipboardMode::Terminal => copy_with_osc52(text).map(|()| "terminal clipboard"),
|
||||
ClipboardMode::Command => copy_with_command(&self.command, text).map(|()| "clipboard"),
|
||||
ClipboardMode::Auto => self
|
||||
.copy_to_system(text)
|
||||
.map(|()| "clipboard")
|
||||
.or_else(|_| copy_with_osc52(text).map(|()| "terminal clipboard")),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_system(&mut self, text: &str) -> Result<(), String> {
|
||||
let clipboard = self.system.as_mut().ok_or("the system clipboard isn't available")?;
|
||||
clipboard.set_text(text).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the OSC 52 escape code: the terminal puts the (base64-encoded)
|
||||
/// text on the clipboard. Terminals without OSC 52 support ignore it.
|
||||
fn copy_with_osc52(text: &str) -> Result<(), String> {
|
||||
let sequence = format!("\x1b]52;c;{}\x07", base64(text.as_bytes()));
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(sequence.as_bytes()).and_then(|()| stdout.flush()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Runs `command` (program and arguments, separated by spaces) and writes
|
||||
/// the text to its input.
|
||||
fn copy_with_command(command: &str, text: &str) -> Result<(), String> {
|
||||
let mut words = command.split_whitespace();
|
||||
let program = words.next().ok_or("the clipboard command setting is empty")?;
|
||||
let mut child = Command::new(program)
|
||||
.args(words)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| format!("can't run {program}: {error}"))?;
|
||||
if let Some(mut input) = child.stdin.take() {
|
||||
input.write_all(text.as_bytes()).map_err(|error| error.to_string())?;
|
||||
// `input` is dropped here, which closes it: the command sees the end of the text.
|
||||
}
|
||||
let status = child.wait().map_err(|error| error.to_string())?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{program} failed ({status})")) }
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648), as OSC 52 requires.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
// Three bytes make 24 bits, written as four 6-bit characters.
|
||||
let byte = |index: usize| u32::from(chunk.get(index).copied().unwrap_or(0));
|
||||
let bits = (byte(0) << 16) | (byte(1) << 8) | byte(2);
|
||||
for position in 0..4 {
|
||||
if position <= chunk.len() {
|
||||
let six_bits = (bits >> (18 - 6 * position)) & 0b11_1111;
|
||||
out.push(char::from(ALPHABET[six_bits as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::base64;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_the_standard() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"5:40\t22:00"), "NTo0MAkyMjowMA==");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! CSV export: one row per work day, for spreadsheets and timesheet tools.
|
||||
|
||||
use super::Report;
|
||||
use crate::time::{DecimalSeparator, DurationStyle};
|
||||
|
||||
pub fn render(report: &Report) -> String {
|
||||
// Where the decimal separator is a comma (French, German...),
|
||||
// spreadsheets expect the columns to be separated by semicolons.
|
||||
let delimiter = match report.fmt.decimal_separator {
|
||||
DecimalSeparator::Dot => ",",
|
||||
DecimalSeparator::Comma => ";",
|
||||
};
|
||||
let header = ["Date", "Weekday", "In", "Out", "Break", "Worked", "Worked (hours)", "Segments"];
|
||||
let mut rows = vec![header.join(delimiter)];
|
||||
|
||||
for day in &report.days {
|
||||
let segments: Vec<String> = day
|
||||
.segments()
|
||||
.iter()
|
||||
.map(|segment| format!("{}-{}", report.time(segment.start()), report.time(segment.end())))
|
||||
.collect();
|
||||
let fields = [
|
||||
day.date().to_string(),
|
||||
day.date().strftime("%a").to_string(),
|
||||
day.first_start().map(|start| report.time(start)).unwrap_or_default(),
|
||||
day.last_end().map(|end| report.time(end)).unwrap_or_default(),
|
||||
report.fmt.duration_as(DurationStyle::Clock, day.break_time()),
|
||||
report.fmt.duration_as(DurationStyle::Clock, day.worked()),
|
||||
report.fmt.decimal_hours(day.worked()),
|
||||
segments.join(" "),
|
||||
];
|
||||
let quoted: Vec<String> = fields.iter().map(|field| quote(field, delimiter)).collect();
|
||||
rows.push(quoted.join(delimiter));
|
||||
}
|
||||
// CSV lines end with CRLF (RFC 4180), which every spreadsheet accepts.
|
||||
rows.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
/// Quotes a field when it contains the delimiter, a quote or a line break
|
||||
/// (doubling the quotes inside), as CSV requires.
|
||||
fn quote(field: &str, delimiter: &str) -> String {
|
||||
if field.contains(delimiter) || field.contains(['"', '\n', '\r']) {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! JSON export: everything, in a machine-friendly shape. Moments are RFC 3339
|
||||
//! timestamps with their UTC offset; durations are whole minutes.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::Report;
|
||||
use crate::time::clock::zone_name;
|
||||
use crate::time::format::{rfc3339, whole_minutes};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonReport {
|
||||
generated: String,
|
||||
time_zone: String,
|
||||
from: String,
|
||||
to: String,
|
||||
worked_minutes: i64,
|
||||
days: Vec<JsonDay>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonDay {
|
||||
date: String,
|
||||
worked_minutes: i64,
|
||||
break_minutes: i64,
|
||||
segments: Vec<JsonSegment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonSegment {
|
||||
start: String,
|
||||
end: String,
|
||||
minutes: i64,
|
||||
}
|
||||
|
||||
pub fn render(report: &Report) -> String {
|
||||
let days = report
|
||||
.days
|
||||
.iter()
|
||||
.map(|day| JsonDay {
|
||||
date: day.date().to_string(),
|
||||
worked_minutes: whole_minutes(day.worked()),
|
||||
break_minutes: whole_minutes(day.break_time()),
|
||||
segments: day
|
||||
.segments()
|
||||
.iter()
|
||||
.map(|segment| JsonSegment {
|
||||
start: rfc3339(&report.local(segment.start())),
|
||||
end: rfc3339(&report.local(segment.end())),
|
||||
minutes: whole_minutes(segment.duration()),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json = JsonReport {
|
||||
generated: rfc3339(&report.generated),
|
||||
time_zone: zone_name(report.zone),
|
||||
from: report.range.first().to_string(),
|
||||
to: report.range.last().to_string(),
|
||||
worked_minutes: whole_minutes(report.total()),
|
||||
days,
|
||||
};
|
||||
// Plain structs of strings and numbers always serialize.
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default() + "\n"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Markdown export: a clean report with a summary and one table per week.
|
||||
//! The tables are padded so the file also reads well as plain text.
|
||||
|
||||
use jiff::civil::Date;
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use super::Report;
|
||||
use crate::domain::WorkDay;
|
||||
use crate::time::DateRange;
|
||||
use crate::time::clock::zone_name;
|
||||
|
||||
pub fn render(report: &Report) -> String {
|
||||
let fmt = &report.fmt;
|
||||
let total = report.total();
|
||||
let days_worked = report.days.len();
|
||||
|
||||
let mut lines = vec![
|
||||
"# Work report".to_string(),
|
||||
String::new(),
|
||||
format!(
|
||||
"**{} - {}** · {}",
|
||||
long_date(report.range.first()),
|
||||
long_date(report.range.last()),
|
||||
zone_name(report.zone)
|
||||
),
|
||||
String::new(),
|
||||
format!("- **Worked:** {} over {days_worked} day{}", fmt.duration(total), plural(days_worked)),
|
||||
];
|
||||
if let Ok(count) = i32::try_from(days_worked)
|
||||
&& count > 0
|
||||
{
|
||||
lines.push(format!("- **Average day:** {}", fmt.duration(total / count)));
|
||||
}
|
||||
if let Some(longest) = report.days.iter().max_by_key(|day| day.worked()) {
|
||||
lines.push(format!(
|
||||
"- **Longest day:** {} ({})",
|
||||
fmt.duration(longest.worked()),
|
||||
fmt.date_short(longest.date())
|
||||
));
|
||||
}
|
||||
|
||||
for (week, days) in group_by_week(report) {
|
||||
let worked: SignedDuration = days.iter().map(|day| day.worked()).sum();
|
||||
let breaks: SignedDuration = days.iter().map(|day| day.break_time()).sum();
|
||||
lines.push(String::new());
|
||||
lines.push(format!("## Week of {} ({})", fmt.date_short(week.first()), fmt.duration(worked)));
|
||||
lines.push(String::new());
|
||||
|
||||
let mut rows: Vec<Vec<String>> = days
|
||||
.iter()
|
||||
.map(|day| {
|
||||
vec![
|
||||
fmt.date_short(day.date()),
|
||||
day.first_start().map(|start| report.time(start)).unwrap_or_default(),
|
||||
day.last_end().map(|end| out_time(report, day, end)).unwrap_or_default(),
|
||||
fmt.duration(day.break_time()),
|
||||
fmt.duration(day.worked()),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
rows.push(vec![
|
||||
"**Total**".into(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
fmt.duration(breaks),
|
||||
fmt.duration(worked),
|
||||
]);
|
||||
lines.extend(table(&["Day", "In", "Out", "Break", "Worked"], &rows));
|
||||
}
|
||||
|
||||
let generated = &report.generated;
|
||||
lines.push(String::new());
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
lines.push(format!(
|
||||
"_Generated by skyshift on {} at {}._",
|
||||
long_date(generated.date()),
|
||||
fmt.time(generated.time())
|
||||
));
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// The end time, marked "(+1)" when it falls on the next calendar day.
|
||||
fn out_time(report: &Report, day: &WorkDay, end: Timestamp) -> String {
|
||||
let days_later = report.local(end).date().duration_since(day.date()).as_hours() / 24;
|
||||
match days_later {
|
||||
0 => report.time(end),
|
||||
later => format!("{} (+{later})", report.time(end)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's days grouped by week, in order.
|
||||
fn group_by_week<'a>(report: &Report<'a>) -> Vec<(DateRange, Vec<&'a WorkDay>)> {
|
||||
let mut weeks: Vec<(DateRange, Vec<&WorkDay>)> = Vec::new();
|
||||
for day in &report.days {
|
||||
let week = DateRange::week_of(day.date(), report.week_start);
|
||||
match weeks.last_mut() {
|
||||
Some((current, days)) if *current == week => days.push(day),
|
||||
_ => weeks.push((week, vec![day])),
|
||||
}
|
||||
}
|
||||
weeks
|
||||
}
|
||||
|
||||
fn long_date(date: Date) -> String {
|
||||
date.strftime("%a %b %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
fn plural(count: usize) -> &'static str {
|
||||
if count == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
/// A Markdown table, one string per line: the first column left-aligned,
|
||||
/// the others right-aligned (they hold times and durations), every column
|
||||
/// padded to the same width.
|
||||
fn table(headers: &[&str], rows: &[Vec<String>]) -> Vec<String> {
|
||||
let widths: Vec<usize> = (0..headers.len())
|
||||
.map(|column| {
|
||||
let longest_cell = rows.iter().map(|row| row[column].chars().count()).max().unwrap_or(0);
|
||||
longest_cell.max(headers[column].chars().count()).max(3)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let pad = |column: usize, text: &str| {
|
||||
let width = widths[column];
|
||||
if column == 0 { format!("{text:<width$}") } else { format!("{text:>width$}") }
|
||||
};
|
||||
let line = |cells: Vec<String>| format!("| {} |", cells.join(" | "));
|
||||
|
||||
let header = line(headers.iter().enumerate().map(|(column, text)| pad(column, text)).collect());
|
||||
let separator = line(
|
||||
widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(column, width)| {
|
||||
let dashes = "-".repeat(width - 1);
|
||||
if column == 0 { format!(":{dashes}") } else { format!("{dashes}:") }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let body =
|
||||
rows.iter().map(|row| line(row.iter().enumerate().map(|(column, cell)| pad(column, cell)).collect()));
|
||||
|
||||
[header, separator].into_iter().chain(body).collect()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Getting your data out of skyshift:
|
||||
//! - [`timesheet`]: the text you copy for your work's timesheet,
|
||||
//! - [`clipboard`]: putting that text on the clipboard,
|
||||
//! - [`csv`], [`json`], [`markdown`]: report files for a range of dates.
|
||||
|
||||
pub mod clipboard;
|
||||
pub mod csv;
|
||||
pub mod json;
|
||||
pub mod markdown;
|
||||
pub mod timesheet;
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use jiff::civil::Weekday;
|
||||
use jiff::tz::TimeZone;
|
||||
use jiff::{SignedDuration, Timestamp, Zoned};
|
||||
|
||||
use crate::domain::WorkDay;
|
||||
use crate::time::{DateRange, Formatter};
|
||||
|
||||
/// The report file formats.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ExportFormat {
|
||||
/// For spreadsheets (Excel, LibreOffice, Google Sheets) and timesheet tools.
|
||||
#[default]
|
||||
Csv,
|
||||
/// For scripts and other apps.
|
||||
Json,
|
||||
/// A readable report for notes apps, emails or printing.
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub const ALL: [ExportFormat; 3] = [ExportFormat::Csv, ExportFormat::Json, ExportFormat::Markdown];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::Csv => "CSV",
|
||||
ExportFormat::Json => "JSON",
|
||||
ExportFormat::Markdown => "Markdown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::Csv => "csv",
|
||||
ExportFormat::Json => "json",
|
||||
ExportFormat::Markdown => "md",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a report file is made from.
|
||||
pub struct Report<'a> {
|
||||
/// The work days in the range that have some work, oldest first.
|
||||
pub days: Vec<&'a WorkDay>,
|
||||
pub range: DateRange,
|
||||
pub fmt: Formatter,
|
||||
pub zone: &'a TimeZone,
|
||||
pub week_start: Weekday,
|
||||
/// When the report was made.
|
||||
pub generated: Zoned,
|
||||
}
|
||||
|
||||
impl<'a> Report<'a> {
|
||||
pub fn new(
|
||||
all_days: &'a [WorkDay],
|
||||
range: DateRange,
|
||||
fmt: Formatter,
|
||||
zone: &'a TimeZone,
|
||||
week_start: Weekday,
|
||||
generated: Zoned,
|
||||
) -> Report<'a> {
|
||||
let days = all_days.iter().filter(|day| range.contains(day.date()) && !day.is_empty()).collect();
|
||||
Report { days, range, fmt, zone, week_start, generated }
|
||||
}
|
||||
|
||||
/// A stored moment as a clock time in the report's time zone.
|
||||
fn time(&self, moment: Timestamp) -> String {
|
||||
self.fmt.time(self.local(moment).time())
|
||||
}
|
||||
|
||||
fn local(&self, moment: Timestamp) -> Zoned {
|
||||
moment.to_zoned(self.zone.clone())
|
||||
}
|
||||
|
||||
fn total(&self) -> SignedDuration {
|
||||
self.days.iter().map(|day| day.worked()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// The report as the contents of a file.
|
||||
pub fn render(format: ExportFormat, report: &Report) -> String {
|
||||
match format {
|
||||
ExportFormat::Csv => csv::render(report),
|
||||
ExportFormat::Json => json::render(report),
|
||||
ExportFormat::Markdown => markdown::render(report),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves an export in `folder`, named after the dates it covers
|
||||
/// ("skyshift_2026-09-01_2026-09-30.csv"), and returns where it went.
|
||||
pub fn save(folder: &Path, format: ExportFormat, range: DateRange, contents: &str) -> io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(folder)?;
|
||||
let name = format!("skyshift_{}_{}.{}", range.first(), range.last(), format.extension());
|
||||
let path = folder.join(name);
|
||||
std::fs::write(&path, contents)?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The text you copy into your work's timesheet.
|
||||
|
||||
use jiff::tz::TimeZone;
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use crate::domain::WorkDay;
|
||||
use crate::time::{DurationStyle, Formatter};
|
||||
|
||||
/// What gets copied for each work day.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CopyPreset {
|
||||
/// Total time as hours and minutes: `5:40`
|
||||
#[default]
|
||||
TotalClock,
|
||||
/// Total time as decimal hours: `5.67`
|
||||
TotalDecimal,
|
||||
/// Every segment: `22:00-01:30, 02:00-04:10`
|
||||
Segments,
|
||||
/// First start, last end and total break, separated by tabs so they fill
|
||||
/// three cells when pasted into a spreadsheet or a web timesheet grid.
|
||||
InOutBreak,
|
||||
/// Your own template (the `custom_template` setting).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl CopyPreset {
|
||||
pub const ALL: [CopyPreset; 5] = [
|
||||
CopyPreset::TotalClock,
|
||||
CopyPreset::TotalDecimal,
|
||||
CopyPreset::Segments,
|
||||
CopyPreset::InOutBreak,
|
||||
CopyPreset::Custom,
|
||||
];
|
||||
|
||||
/// A short description with an example, for menus.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyPreset::TotalClock => "Total as H:MM (5:40)",
|
||||
CopyPreset::TotalDecimal => "Total as decimal hours (5.67)",
|
||||
CopyPreset::Segments => "Each segment (22:00-01:30, 02:00-04:10)",
|
||||
CopyPreset::InOutBreak => "In, Out, Break (tab-separated cells)",
|
||||
CopyPreset::Custom => "Custom template",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the total is rounded for the timesheet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Rounding {
|
||||
#[default]
|
||||
None,
|
||||
Nearest,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
impl Rounding {
|
||||
/// Rounds `duration` to a multiple of `step` (for example 15 minutes).
|
||||
/// A zero step leaves the duration unchanged.
|
||||
pub fn apply(self, duration: SignedDuration, step: SignedDuration) -> SignedDuration {
|
||||
let (value, step) = (duration.as_secs(), step.as_secs());
|
||||
if step <= 0 {
|
||||
return duration;
|
||||
}
|
||||
let rounded = match self {
|
||||
Rounding::None => return duration,
|
||||
Rounding::Down => value.div_euclid(step) * step,
|
||||
Rounding::Up => -(-value).div_euclid(step) * step,
|
||||
Rounding::Nearest => (value + step / 2).div_euclid(step) * step,
|
||||
};
|
||||
SignedDuration::from_secs(rounded)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that shapes the copied text, taken from the settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CopyOptions {
|
||||
pub fmt: Formatter,
|
||||
pub zone: TimeZone,
|
||||
pub rounding: Rounding,
|
||||
pub rounding_step: SignedDuration,
|
||||
/// Used by [`CopyPreset::Custom`]: text with placeholders, filled by
|
||||
/// `fill_template` below.
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
/// The text to copy for one work day.
|
||||
pub fn day_text(day: &WorkDay, preset: CopyPreset, options: &CopyOptions) -> String {
|
||||
match preset {
|
||||
CopyPreset::TotalClock => fill_template("{total}", day, options),
|
||||
CopyPreset::TotalDecimal => fill_template("{decimal}", day, options),
|
||||
CopyPreset::Segments => fill_template("{segments}", day, options),
|
||||
CopyPreset::InOutBreak => fill_template("{in}{tab}{out}{tab}{break}", day, options),
|
||||
CopyPreset::Custom => fill_template(&options.template, day, options),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text to copy for several work days: one line per day, so pasting
|
||||
/// fills consecutive rows.
|
||||
pub fn days_text(days: &[&WorkDay], preset: CopyPreset, options: &CopyOptions) -> String {
|
||||
days.iter().map(|day| day_text(day, preset, options)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Replaces every placeholder of `template` with the day's values:
|
||||
///
|
||||
/// | Placeholder | Becomes |
|
||||
/// |--------------|-----------------------------------------|
|
||||
/// | `{date}` | 2026-09-24 |
|
||||
/// | `{day}` | Thu Sep 24 |
|
||||
/// | `{in}` | the first start |
|
||||
/// | `{out}` | the last end |
|
||||
/// | `{break}` | total break, H:MM |
|
||||
/// | `{total}` | time worked, H:MM (rounded) |
|
||||
/// | `{decimal}` | time worked in hours (rounded) |
|
||||
/// | `{segments}` | 22:00-01:30, 02:00-04:10 |
|
||||
/// | `{tab}` | a tab, which jumps to the next cell |
|
||||
fn fill_template(template: &str, day: &WorkDay, options: &CopyOptions) -> String {
|
||||
let fmt = &options.fmt;
|
||||
let total = options.rounding.apply(day.worked(), options.rounding_step);
|
||||
let time = |moment: Option<Timestamp>| {
|
||||
moment.map(|moment| fmt.time(moment.to_zoned(options.zone.clone()).time())).unwrap_or_default()
|
||||
};
|
||||
let segments = day
|
||||
.segments()
|
||||
.iter()
|
||||
.map(|segment| format!("{}-{}", time(Some(segment.start())), time(Some(segment.end()))))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
template
|
||||
.replace("{date}", &day.date().to_string())
|
||||
.replace("{day}", &fmt.date_short(day.date()))
|
||||
.replace("{in}", &time(day.first_start()))
|
||||
.replace("{out}", &time(day.last_end()))
|
||||
.replace("{break}", &fmt.duration_as(DurationStyle::Clock, day.break_time()))
|
||||
.replace("{total}", &fmt.duration_as(DurationStyle::Clock, total))
|
||||
.replace("{decimal}", &fmt.decimal_hours(total))
|
||||
.replace("{segments}", &segments)
|
||||
.replace("{tab}", "\t")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{Journal, Segment, ShiftState};
|
||||
use jiff::civil::date;
|
||||
|
||||
fn night_shift() -> WorkDay {
|
||||
let at = |day: i8, hour: i8, minute: i8| {
|
||||
date(2026, 9, day).at(hour, minute, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp()
|
||||
};
|
||||
let segments = vec![
|
||||
Segment::new(at(24, 22, 0), at(25, 1, 30)).unwrap(),
|
||||
Segment::new(at(25, 2, 0), at(25, 4, 10)).unwrap(),
|
||||
];
|
||||
let journal = Journal::from_parts(vec![(date(2026, 9, 24), segments)], ShiftState::Idle).unwrap();
|
||||
journal.days().next().unwrap().clone()
|
||||
}
|
||||
|
||||
fn options(rounding: Rounding) -> CopyOptions {
|
||||
CopyOptions {
|
||||
fmt: Formatter::default(),
|
||||
zone: TimeZone::UTC,
|
||||
rounding,
|
||||
rounding_step: SignedDuration::from_mins(15),
|
||||
template: "{date}{tab}{total}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets() {
|
||||
let day = night_shift();
|
||||
let options = options(Rounding::None);
|
||||
assert_eq!(day_text(&day, CopyPreset::TotalClock, &options), "5:40");
|
||||
assert_eq!(day_text(&day, CopyPreset::TotalDecimal, &options), "5.67");
|
||||
assert_eq!(day_text(&day, CopyPreset::Segments, &options), "22:00-01:30, 02:00-04:10");
|
||||
assert_eq!(day_text(&day, CopyPreset::InOutBreak, &options), "22:00\t04:10\t0:30");
|
||||
assert_eq!(day_text(&day, CopyPreset::Custom, &options), "2026-09-24\t5:40");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_to_quarter_hours() {
|
||||
let day = night_shift();
|
||||
assert_eq!(day_text(&day, CopyPreset::TotalClock, &options(Rounding::Nearest)), "5:45");
|
||||
assert_eq!(day_text(&day, CopyPreset::TotalClock, &options(Rounding::Down)), "5:30");
|
||||
assert_eq!(day_text(&day, CopyPreset::TotalDecimal, &options(Rounding::Up)), "5.75");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
mod analysis;
|
||||
mod domain;
|
||||
mod export;
|
||||
mod time;
|
||||
|
||||
fn main() {}
|
||||
Reference in new issue
Block a user