| 336 |
336 |
|
//! columns never entered into it — a drop's effect is "set status", a discrete
|
| 337 |
337 |
|
//! action `Row`'s menu already carries, and the drag itself is affordance.
|
| 338 |
338 |
|
//!
|
| 339 |
|
- |
//! **Calendar: no members, and no consumer.** A month grid renders today as a
|
|
339 |
+ |
//! **Calendar: no members, no consumer, and a sharper reason (Max,
|
|
340 |
+ |
//! 2026-08-15).** The month grid's primacy in calendar apps is an artifact of
|
|
341 |
+ |
//! paper: paper cannot be queried, so it has to show every day at once as a
|
|
342 |
+ |
//! fallback index. Routes, search and ranking do that job better, which is the
|
|
343 |
+ |
//! argument `events-calendar.js` already lost to a segmented list on
|
|
344 |
+ |
//! 2026-08-11.
|
|
345 |
+ |
//!
|
|
346 |
+ |
//! Three jobs survive that reasoning, and only one of them needs a grid:
|
|
347 |
+ |
//!
|
|
348 |
+ |
//! 1. **Spans across days** — a stretch of leave, a trip, a sprint. You cannot
|
|
349 |
+ |
//! see "away the 3rd to the 17th" in a list without diffing dates. This is
|
|
350 |
+ |
//! [`Track`] with [`Unit::Days`], not a calendar, and
|
|
351 |
+ |
//! [`Track::days`] is it.
|
|
352 |
+ |
//! 2. **Density at a glance** — which weeks were heavy. That is a heatmap, and
|
|
353 |
+ |
//! goingson describes both of its heatmaps as lists already.
|
|
354 |
+ |
//! 3. **Weekday periodicity** — "every other Tuesday", "the 15th is a
|
|
355 |
+ |
//! Saturday". This is the only job that needs the seven-column wrap, because
|
|
356 |
+ |
//! alignment is the whole of what makes it visible.
|
|
357 |
+ |
//!
|
|
358 |
+ |
//! So the open question is not "is a calendar describable" but "is job 3 worth
|
|
359 |
+ |
//! a member", and nothing in the tree asks for job 3 yet. GoingsOn
|
|
360 |
+ |
//! quasicoherent `4a1237b6`.
|
|
361 |
+ |
//!
|
|
362 |
+ |
//! A month grid renders today as a
|
| 340 |
363 |
|
//! [`Table`](crate::Column): seven weekday columns, weeks as rows, blanks for
|
| 341 |
364 |
|
//! the offset. goingson's monthly review reached this conclusion before this
|
| 342 |
365 |
|
//! note did and describes its month as a list of days that had something on
|
| 1541 |
1564 |
|
}
|
| 1542 |
1565 |
|
}
|
| 1543 |
1566 |
|
|
| 1544 |
|
- |
/// A window of time, in minutes from the start of a day.
|
|
1567 |
+ |
/// What a [`Track`]'s integers count.
|
| 1545 |
1568 |
|
///
|
| 1546 |
|
- |
/// The axis a [`Track`] draws. Minutes rather than instants, and offsets rather
|
| 1547 |
|
- |
/// than wall-clock, because a description that carried a `DateTime` would carry
|
| 1548 |
|
- |
/// a timezone with it and the vocabulary has no business holding one. The app
|
| 1549 |
|
- |
/// knows which day this is; the description says how far along it a thing sits.
|
|
1569 |
+ |
/// `Track::fraction` never needed this -- the arithmetic is the same whatever
|
|
1570 |
+ |
/// the numbers mean -- which is exactly how the ruler came to assume minutes
|
|
1571 |
+ |
/// and print `00:00` over a month. A renderer drawing an axis has to write a
|
|
1572 |
+ |
/// label, and it cannot derive the unit from the numbers.
|
| 1550 |
1573 |
|
///
|
| 1551 |
|
- |
/// `to` is exclusive and may exceed `1440`, which is how a span that runs past
|
| 1552 |
|
- |
/// midnight is said without a second date: `Span::new(1320, 1560)` is 22:00 to
|
| 1553 |
|
- |
/// 02:00.
|
|
1574 |
+ |
/// Added 2026-08-15, after a probe put a fifteen-day span on a
|
|
1575 |
+ |
/// thirty-one-slot track and got correct geometry under a wall clock.
|
|
1576 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
|
1577 |
+ |
#[non_exhaustive]
|
|
1578 |
+ |
pub enum Unit {
|
|
1579 |
+ |
/// Minutes from the start of a day. A day view.
|
|
1580 |
+ |
#[default]
|
|
1581 |
+ |
Minutes,
|
|
1582 |
+ |
/// Whole days. A month strip, a sprint, a stretch of leave.
|
|
1583 |
+ |
///
|
|
1584 |
+ |
/// A day-granularity axis is a *strip*, not a calendar: one line with
|
|
1585 |
+ |
/// spans laid along it. What it deliberately does not do is wrap into
|
|
1586 |
+ |
/// weeks, which is the shape that makes weekday periodicity visible and
|
|
1587 |
+ |
/// the one job of a month grid that a strip cannot take over. See the
|
|
1588 |
+ |
/// crate header.
|
|
1589 |
+ |
Days,
|
|
1590 |
+ |
}
|
|
1591 |
+ |
|
|
1592 |
+ |
/// A window on an axis, in whatever [`Unit`] its [`Track`] counts.
|
|
1593 |
+ |
///
|
|
1594 |
+ |
/// The axis a [`Track`] draws. Offsets rather than instants, because a
|
|
1595 |
+ |
/// description carrying a `DateTime` would carry a timezone with it and the
|
|
1596 |
+ |
/// vocabulary has no business holding one. The app knows which day or month
|
|
1597 |
+ |
/// this is; the description says how far along it a thing sits.
|
|
1598 |
+ |
///
|
|
1599 |
+ |
/// `to` is exclusive and may exceed the natural period, which is how a span
|
|
1600 |
+ |
/// running past the end is said without a second date: under
|
|
1601 |
+ |
/// [`Unit::Minutes`], `Span::new(1320, 1560)` is 22:00 to 02:00.
|
| 1554 |
1602 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
| 1555 |
1603 |
|
pub struct Span {
|
| 1556 |
1604 |
|
from: u16,
|
| 1587 |
1635 |
|
self.to
|
| 1588 |
1636 |
|
}
|
| 1589 |
1637 |
|
|
| 1590 |
|
- |
/// How many minutes the axis covers. Never zero.
|
|
1638 |
+ |
/// How much the axis covers, in its track's unit. Never zero.
|
| 1591 |
1639 |
|
#[must_use]
|
| 1592 |
|
- |
pub const fn minutes(self) -> u16 {
|
|
1640 |
+ |
pub const fn length(self) -> u16 {
|
| 1593 |
1641 |
|
self.to - self.from
|
| 1594 |
1642 |
|
}
|
| 1595 |
1643 |
|
|
| 1596 |
|
- |
/// Whether a minute falls on this axis.
|
|
1644 |
+ |
/// Whether an offset falls on this axis.
|
| 1597 |
1645 |
|
#[must_use]
|
| 1598 |
1646 |
|
pub const fn holds(self, minute: u16) -> bool {
|
| 1599 |
1647 |
|
minute >= self.from && minute < self.to
|
| 1615 |
1663 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
| 1616 |
1664 |
|
pub struct Placement {
|
| 1617 |
1665 |
|
at: u16,
|
| 1618 |
|
- |
minutes: u16,
|
|
1666 |
+ |
length: u16,
|
| 1619 |
1667 |
|
}
|
| 1620 |
1668 |
|
|
| 1621 |
1669 |
|
impl Placement {
|
| 1622 |
1670 |
|
/// A placement, clamped to a drawable one.
|
| 1623 |
1671 |
|
///
|
| 1624 |
|
- |
/// Zero minutes becomes one for the same reason [`Span::new`] clamps: a
|
|
1672 |
+ |
/// Zero length becomes one for the same reason [`Span::new`] clamps: a
|
| 1625 |
1673 |
|
/// zero-height thing is invisible rather than expressive, and every
|
| 1626 |
1674 |
|
/// renderer would need its own guard.
|
| 1627 |
1675 |
|
#[must_use]
|
| 1628 |
|
- |
pub const fn new(at: u16, minutes: u16) -> Self {
|
|
1676 |
+ |
pub const fn new(at: u16, length: u16) -> Self {
|
| 1629 |
1677 |
|
Self {
|
| 1630 |
1678 |
|
at,
|
| 1631 |
|
- |
minutes: if minutes == 0 { 1 } else { minutes },
|
|
1679 |
+ |
length: if length == 0 { 1 } else { length },
|
| 1632 |
1680 |
|
}
|
| 1633 |
1681 |
|
}
|
| 1634 |
1682 |
|
|
| 1635 |
|
- |
/// Minutes from the start of the day, matching [`Span`]'s origin.
|
|
1683 |
+ |
/// Offset from the axis origin, matching [`Span`]'s.
|
| 1636 |
1684 |
|
#[must_use]
|
| 1637 |
1685 |
|
pub const fn at(self) -> u16 {
|
| 1638 |
1686 |
|
self.at
|
| 1639 |
1687 |
|
}
|
| 1640 |
1688 |
|
|
| 1641 |
|
- |
/// How long it lasts. Never zero.
|
|
1689 |
+ |
/// How long it lasts, in its track's unit. Never zero.
|
| 1642 |
1690 |
|
#[must_use]
|
| 1643 |
|
- |
pub const fn minutes(self) -> u16 {
|
| 1644 |
|
- |
self.minutes
|
|
1691 |
+ |
pub const fn length(self) -> u16 {
|
|
1692 |
+ |
self.length
|
| 1645 |
1693 |
|
}
|
| 1646 |
1694 |
|
|
| 1647 |
1695 |
|
/// One past its last minute.
|
| 1648 |
1696 |
|
#[must_use]
|
| 1649 |
1697 |
|
pub const fn end(self) -> u16 {
|
| 1650 |
|
- |
self.at + self.minutes
|
|
1698 |
+ |
self.at + self.length
|
| 1651 |
1699 |
|
}
|
| 1652 |
1700 |
|
|
| 1653 |
1701 |
|
/// Whether two placements cover any of the same time.
|
| 1711 |
1759 |
|
/// constrain [`Placement`], because data arriving from a calendar does not
|
| 1712 |
1760 |
|
/// respect anyone's grid.
|
| 1713 |
1761 |
|
pub slot: u16,
|
| 1714 |
|
- |
/// How often the axis labels itself, in minutes.
|
|
1762 |
+ |
/// How often the axis labels itself, in its own unit.
|
| 1715 |
1763 |
|
///
|
| 1716 |
1764 |
|
/// 60 gives an hourly ruler over a 15-minute grid, which is the common
|
| 1717 |
1765 |
|
/// shape and the reason this is separate from `slot`. Zero means an
|
| 1718 |
1766 |
|
/// unlabelled axis.
|
| 1719 |
1767 |
|
pub tick: u16,
|
|
1768 |
+ |
/// What `span`, `slot`, `tick` and every [`Placement`] on it count.
|
|
1769 |
+ |
///
|
|
1770 |
+ |
/// The one field here a renderer cannot derive, and the reason it exists:
|
|
1771 |
+ |
/// [`fraction`](Self::fraction) is unit-agnostic, so a day-granularity
|
|
1772 |
+ |
/// track produced correct geometry under an hours-and-minutes ruler until
|
|
1773 |
+ |
/// this was added. Geometry never needed it; a label always did.
|
|
1774 |
+ |
pub unit: Unit,
|
| 1720 |
1775 |
|
}
|
| 1721 |
1776 |
|
|
| 1722 |
1777 |
|
impl Track {
|
| 1725 |
1780 |
|
span: Span::DAY,
|
| 1726 |
1781 |
|
slot: 15,
|
| 1727 |
1782 |
|
tick: 60,
|
|
1783 |
+ |
unit: Unit::Minutes,
|
| 1728 |
1784 |
|
};
|
| 1729 |
1785 |
|
|
| 1730 |
1786 |
|
/// A track over `span`, with the day's usual granularity.
|
| 1734 |
1790 |
|
span,
|
| 1735 |
1791 |
|
slot: 15,
|
| 1736 |
1792 |
|
tick: 60,
|
|
1793 |
+ |
unit: Unit::Minutes,
|
|
1794 |
+ |
}
|
|
1795 |
+ |
}
|
|
1796 |
+ |
|
|
1797 |
+ |
/// A strip of whole days: one slot a day, a label a week.
|
|
1798 |
+ |
///
|
|
1799 |
+ |
/// The shape a stretch of leave or a sprint is drawn on. Not a calendar --
|
|
1800 |
+ |
/// it does not wrap into weeks, and the crate header says why that
|
|
1801 |
+ |
/// distinction is the whole of what a month grid still has over this.
|
|
1802 |
+ |
#[must_use]
|
|
1803 |
+ |
pub const fn days(span: Span) -> Self {
|
|
1804 |
+ |
Self {
|
|
1805 |
+ |
span,
|
|
1806 |
+ |
slot: 1,
|
|
1807 |
+ |
tick: 7,
|
|
1808 |
+ |
unit: Unit::Days,
|
| 1737 |
1809 |
|
}
|
| 1738 |
1810 |
|
}
|
| 1739 |
1811 |
|
|
| 1749 |
1821 |
|
if self.slot == 0 {
|
| 1750 |
1822 |
|
1
|
| 1751 |
1823 |
|
} else {
|
| 1752 |
|
- |
self.span.minutes().div_ceil(self.slot)
|
|
1824 |
+ |
self.span.length().div_ceil(self.slot)
|
| 1753 |
1825 |
|
}
|
| 1754 |
1826 |
|
}
|
| 1755 |
1827 |
|
|
| 1762 |
1834 |
|
/// panicking or drawing it somewhere impossible.
|
| 1763 |
1835 |
|
#[must_use]
|
| 1764 |
1836 |
|
pub fn fraction(self, minute: u16) -> f32 {
|
| 1765 |
|
- |
let span = f32::from(self.span.minutes());
|
|
1837 |
+ |
let span = f32::from(self.span.length());
|
| 1766 |
1838 |
|
let offset = f32::from(minute.saturating_sub(self.span.from()));
|
| 1767 |
1839 |
|
(offset / span).clamp(0.0, 1.0)
|
| 1768 |
1840 |
|
}
|
| 3520 |
3592 |
|
fn a_span_never_has_zero_minutes_however_it_is_asked_for() {
|
| 3521 |
3593 |
|
// Every renderer divides by this. A caller passing a backwards or empty
|
| 3522 |
3594 |
|
// span is a bug, but it is not a bug worth a panic three renderers deep.
|
| 3523 |
|
- |
assert_eq!(Span::new(600, 600).minutes(), 1);
|
| 3524 |
|
- |
assert_eq!(Span::new(600, 300).minutes(), 1);
|
| 3525 |
|
- |
assert_eq!(Span::DAY.minutes(), 1440);
|
|
3595 |
+ |
assert_eq!(Span::new(600, 600).length(), 1);
|
|
3596 |
+ |
assert_eq!(Span::new(600, 300).length(), 1);
|
|
3597 |
+ |
assert_eq!(Span::DAY.length(), 1440);
|
| 3526 |
3598 |
|
}
|
| 3527 |
3599 |
|
|
| 3528 |
3600 |
|
#[test]
|
| 3530 |
3602 |
|
// 22:00 to 02:00. The alternative was carrying a date, which drags a
|
| 3531 |
3603 |
|
// timezone into the vocabulary for the sake of one night shift.
|
| 3532 |
3604 |
|
let overnight = Span::new(1320, 1560);
|
| 3533 |
|
- |
assert_eq!(overnight.minutes(), 240);
|
|
3605 |
+ |
assert_eq!(overnight.length(), 240);
|
| 3534 |
3606 |
|
assert!(overnight.holds(1500));
|
| 3535 |
3607 |
|
assert!(!overnight.holds(1200));
|
| 3536 |
3608 |
|
}
|
| 3554 |
3626 |
|
|
| 3555 |
3627 |
|
#[test]
|
| 3556 |
3628 |
|
fn a_placement_is_always_drawable() {
|
| 3557 |
|
- |
assert_eq!(Placement::new(540, 0).minutes(), 1);
|
|
3629 |
+ |
assert_eq!(Placement::new(540, 0).length(), 1);
|
| 3558 |
3630 |
|
assert_eq!(Placement::new(540, 30).end(), 570);
|
| 3559 |
3631 |
|
}
|
| 3560 |
3632 |
|
|
| 3579 |
3651 |
|
span: Span::DAY,
|
| 3580 |
3652 |
|
slot: 0,
|
| 3581 |
3653 |
|
tick: 60,
|
|
3654 |
+ |
unit: Unit::Minutes,
|
| 3582 |
3655 |
|
};
|
| 3583 |
3656 |
|
assert_eq!(degenerate.slots(), 1);
|
| 3584 |
3657 |
|
}
|
| 3593 |
3666 |
|
assert_eq!(day.span, Span::DAY);
|
| 3594 |
3667 |
|
assert_eq!(day.slot, 15);
|
| 3595 |
3668 |
|
assert_eq!(day.tick, 60);
|
| 3596 |
|
- |
// Three fields. Adding a fourth should have to come here and say why.
|
| 3597 |
|
- |
assert_eq!(std::mem::size_of::<Track>(), 8);
|
|
3669 |
+ |
assert_eq!(day.unit, Unit::Minutes);
|
|
3670 |
+ |
|
|
3671 |
+ |
// Three fields when this was written, and the fourth came here to say
|
|
3672 |
+ |
// why, which is the whole point of the assertion. `unit` is what the
|
|
3673 |
+ |
// integers COUNT -- a fact about the data, unavailable from the numbers
|
|
3674 |
+ |
// themselves, and the absence of it is what let a month strip render
|
|
3675 |
+ |
// under a wall clock. A fifth field still has to argue, and "the
|
|
3676 |
+ |
// renderer would find it handy" is still not the argument.
|
|
3677 |
+ |
let _exhaustive = Track {
|
|
3678 |
+ |
span: day.span,
|
|
3679 |
+ |
slot: day.slot,
|
|
3680 |
+ |
tick: day.tick,
|
|
3681 |
+ |
unit: day.unit,
|
|
3682 |
+ |
};
|
|
3683 |
+ |
}
|
|
3684 |
+ |
|
|
3685 |
+ |
#[test]
|
|
3686 |
+ |
fn a_day_strip_is_the_same_arithmetic_under_a_different_unit() {
|
|
3687 |
+ |
// The probe that found the defect, kept as a test. Fifteen days from
|
|
3688 |
+ |
// day three, on a thirty-one day month: the geometry was always right
|
|
3689 |
+ |
// and only the label was wrong, which is why `unit` is a fact and not
|
|
3690 |
+ |
// presentation.
|
|
3691 |
+ |
let march = Track::days(Span::new(0, 31));
|
|
3692 |
+ |
assert_eq!(march.slots(), 31);
|
|
3693 |
+ |
assert_eq!(march.unit, Unit::Days);
|
|
3694 |
+ |
|
|
3695 |
+ |
let leave = Placement::new(2, 15);
|
|
3696 |
+ |
assert!((march.fraction(leave.at()) - 2.0 / 31.0).abs() < 0.0001);
|
|
3697 |
+ |
assert!((march.fraction(leave.end()) - 17.0 / 31.0).abs() < 0.0001);
|
| 3598 |
3698 |
|
}
|
| 3599 |
3699 |
|
|
| 3600 |
3700 |
|
#[test]
|