Skip to main content

max / makeover-layout

0.26.0: a track says what its integers count Unit, and Track::unit. Found by probing a fifteen-day span on a thirty-one-slot track: the geometry came out exactly right and the ruler printed 00:00 over a month. That is the shape of the defect. Track::fraction is unit-agnostic, so nothing about the arithmetic ever needed to know, and a renderer drawing an axis has to write a label and cannot derive the unit from the numbers. Geometry never needed it; a label always did. Span::minutes and Placement::minutes become length, since they were named for one unit and are read under two. Breaking, and forward-fixed in the same pass -- both were internal to this crate and its tests, so no consumer moved. Track::days is the strip a stretch of leave or a sprint is drawn on: one slot a day, a label a week. Deliberately not a calendar, because it does not wrap into weeks, and the wrap is the whole of what a month grid still has over a strip. The header carries Max's reframing of the calendar question. The month grid's primacy is an artifact of paper needing to show every day at once as a fallback index, which routes and search do better. Three jobs survive: spans across days (this unit), density at a glance (a heatmap, already a list), and weekday periodicity (the only one needing the wrap). Nothing in the tree asks for the third. The four-field guard on Track is kept and now says why the fourth arrived: a unit is what the integers COUNT, unavailable from the numbers, and the absence of it is what let a month strip render under a wall clock. A fifth still has to argue.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 16:51 UTC
Signed with PGP, not checked
Commit: 7e9806b36eb375ce5170595ddc5b8bc29f5a60fa
Parent: 3b44f6a
2 files changed, +132 insertions, -32 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.25.0"
3 + version = "0.26.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +131 -31
@@ -336,7 +336,30 @@
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,16 +1564,41 @@
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,13 +1635,13 @@
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,39 +1663,39 @@
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,12 +1759,19 @@
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,6 +1780,7 @@
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,6 +1790,22 @@
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,7 +1821,7 @@
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,7 +1834,7 @@
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,9 +3592,9 @@
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,7 +3602,7 @@
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,7 +3626,7 @@
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,6 +3651,7 @@
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,8 +3666,35 @@
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]