@lotics/ui 46.14.0 → 47.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -61,6 +61,26 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
61
61
  Worked in `examples/tpl_record.tsx` § Progress.
62
62
  → [catalog.md §"Tasks, to-dos, phased work"](./docs/catalog.md),
63
63
  [catalog.md §"Tasks & checklists"](./docs/catalog.md).
64
+ - **A calendar is for WHEN; a Gantt is for WHO/WHAT.** Days on the axis → `CalendarView`
65
+ (compound: `CalendarToolbar` + `CalendarMonth`/`CalendarWeek`/`CalendarDay`/`CalendarAgenda`,
66
+ controlled via `date`/`view`). Resources on the axis — a channel, an owner, a vehicle →
67
+ `GanttView`, which draws lanes (`group`), milestones and progress. And
68
+ a grid is unreadable in TWO directions: too narrow OR too short, month/week fall back to the
69
+ agenda on their own, so never wrap one in a horizontal scroller to force it.
70
+ → [catalog.md §"Scheduling & time"](./docs/catalog.md).
71
+ - **A repeating event is ONE row with a `repeat`, and there is no `timeZone` prop.** The
72
+ calendar expands a repeat to the view's own range, so a timetable is one event rather than a
73
+ year of them; an occurrence's id resolves back to the source row. And a stored Lotics
74
+ datetime is a naive WALL CLOCK — "14:30" means 14:30 to the business — so there is nothing to
75
+ convert and a calendar that re-projected it would move every event. Only *what day it is*
76
+ depends on the reader's location: that is the `now` prop.
77
+ → [catalog.md §"Scheduling & time"](./docs/catalog.md).
78
+ - **A wash gives a surface its body; a border — or an accent stripe down one side — is that
79
+ same edge asserted twice.** Light ground + dark ink of one family (`Badge`'s tonal pairing),
80
+ never the solid shade under white text; `solid()` is for a DOT, where a few pixels carry
81
+ identity. And a radius is a PROPORTION of the box (`proportionalRadius`), because a component
82
+ sized by data renders as a pill at one height and barely a corner at another.
83
+ → [composition.md §"A body, not an edge"](./docs/composition.md).
64
84
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
65
85
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
66
86
  never a second chip. →
package/MIGRATION.md CHANGED
@@ -4,6 +4,64 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 47.0.0
8
+
9
+ **`@lotics/ui/calendar` is a compound, and `CalendarEvent.color` is a token.**
10
+
11
+ `CalendarView` still exists and `<CalendarView events={…} />` still renders a working calendar,
12
+ so the drop-in call site is unchanged. Four things did change:
13
+
14
+ - **`MonthView` / `TimeGridView` are gone.** The parts are `CalendarMonth`, `CalendarWeek`,
15
+ `CalendarDay`, `CalendarAgenda`, `CalendarToolbar` and `CalendarBody`, and they read their
16
+ state from the root rather than taking `date`/`events` props. Mount them as children of
17
+ `CalendarView` to compose your own chrome.
18
+ - **`color` is a `ColorName`, not a string.** `color: solid("blue")` becomes `color: "blue"`.
19
+ The views derive a fill, a tint and a border from the token; the old code appended `"1f"` to
20
+ the string for the tint, which produced garbage for anything that was not a 6-digit hex.
21
+ - **`CalendarLabels` is `CalendarViewLabels`, and it is provider-wired.** Strings now resolve
22
+ prop → `LoticsLocale.calendarView` → English, so a localized app can delete its hand-passed
23
+ set. `DEFAULT_CALENDAR_LABELS` is `DEFAULT_CALENDAR_VIEW_LABELS`. (`CalendarLabels` still
24
+ exists and still belongs to `@lotics/ui/date_calendar`, the date PICKER's month grid — the
25
+ two had the same name.) Two labels are new: `agenda`, `noEvents`, `addAt`.
26
+ - **`onEventDrop` is gone.** Dragging an event to another day wrote through no seam an app
27
+ could gate. Press-to-open is the gesture: `onEventPress`, plus `onSlotPress(start, end)` on
28
+ an empty day cell or hour slot.
29
+
30
+ **Two behaviours changed without an API change, and both are worth looking at.** A week or day
31
+ grid now opens on the earliest event IN VIEW rather than an hour before the wall clock (a week
32
+ of 09:00 meetings viewed at 13:00 used to open on empty grid). And month/week fall back to the
33
+ agenda when the surface is too narrow OR too short for a grid to name a single event — if you
34
+ wrapped a calendar in a horizontal `ScrollView` with a `minWidth` to work around the old
35
+ behaviour, delete that: it now fights the fallback.
36
+
37
+ **Additive in the same release:** `CalendarEvent.repeat` expands a series to the view's own
38
+ range (a timetable is one row, not a year of them) and `CalendarView`'s `now` supplies the
39
+ reference instant for the today badge, the now line and the opening scroll — which also
40
+ makes "today" pinnable in a test. There is deliberately **no `timeZone` prop**: Lotics
41
+ stores a datetime as a naive wall clock, so a calendar that re-projected it would move
42
+ every event.
43
+
44
+ ### `GanttView`, in the same major
45
+
46
+ It keeps the promise its type was already making, and `color` becomes a token.
47
+
48
+ - **`group` now renders.** It was declared on `GanttTask`, documented as a lane label, passed by
49
+ callers — and read by nothing; every task rendered in one flat list. Tasks sharing a `group`
50
+ now sit under one heading, in first-seen order, with ungrouped tasks leading. If you were
51
+ passing `group` and relying on the flat rendering, drop the field.
52
+ - **`color` is a `ColorName`, not a string** — `solid("blue")` becomes `"blue"`, matching
53
+ `CalendarEvent`.
54
+ - **`onTaskResize` is gone.** Dragging a bar's edge wrote through no seam an app could gate,
55
+ the same reason the calendar's drag went in 47.0.0. Press is the gesture.
56
+ - **Labels are provider-wired** — prop → `LoticsLocale.gantt` → English, so a localized app can
57
+ delete its hand-passed set. One label is new: `empty`.
58
+
59
+ New, additive: `milestone` draws a diamond, `progress` fills part of a bar. `buildRows` is
60
+ exported for anything that needs the same lane geometry.
61
+
62
+ There are deliberately **no dependency connectors**. Lane order and bar position already carry
63
+ the sequence, and elbow arrows over an already-ruled grid restate it as line noise.
64
+
7
65
  ## 46.3.0
8
66
 
9
67
  **A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
package/docs/catalog.md CHANGED
@@ -482,7 +482,8 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
482
482
  `ScanField` (scan/verify), `Stepper` (a guided run / progress sequence — done, current,
483
483
  upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
484
484
  `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
485
- `Calendar` (the `calendar` module's views), `Gantt`, `comments_thread`.
485
+ `CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
486
+ axis: a channel, an owner, a vehicle), `comments_thread`.
486
487
 
487
488
  ### AI surfaces
488
489
 
@@ -2048,14 +2049,42 @@ component rather than showing it at zero.
2048
2049
 
2049
2050
  ### Scheduling & time
2050
2051
 
2051
- - **`calendar`** — the calendar views: `CalendarView` (the composed month/week surface),
2052
- `TimeGridView`, `MonthView`, plus the layout/date helpers (`layoutDayColumns`,
2053
- `packEventLanes`, `addDays`, `startOfWeek`, …) and `CalendarEvent`/`CalendarLabels` types.
2054
- **Limitation:** labels are not provider-wired pass `labels`
2055
- (`DEFAULT_CALENDAR_LABELS` is English).
2056
- - **`gantt`** — `GanttView` + scale helpers (`barGeometry`, `axisRange`, `buildTicks`,
2057
- `pxPerDay`) + `GanttTask`/`GanttLabels` types. **Limitation:** labels are not
2058
- provider-wired pass `labels` (`DEFAULT_GANTT_LABELS` is English).
2052
+ - **`calendar`** — a COMPOUND calendar of events. `CalendarView` is the root and holds the
2053
+ state; `CalendarToolbar`, `CalendarMonth`, `CalendarWeek`, `CalendarDay`, `CalendarAgenda`
2054
+ and `CalendarBody` are the parts. Mount it bare (`<CalendarView events={} />`) for the
2055
+ toolbar plus the current view, or pass children to compose your own chrome — an app screen
2056
+ already has a header band, and a calendar that welds one on stacks two. `date` / `view` with
2057
+ their `on*Change` make it controlled, so a filter chip or a route can drive it. It NEVER
2058
+ writes: `onEventPress` and `onSlotPress` hand you the event or an empty range and your own
2059
+ form does the rest. `renderEvent` replaces a chip's contents while the calendar keeps the
2060
+ press target and the geometry. Also `EventChip`, `useCalendar`, and the pure helpers
2061
+ (`layoutDayColumns`, `packEventLanes`, `compareEvents`, `fitLanes`, `initialScrollMinutes`,
2062
+ `gridWindow`, `weekRowsInMonth`, `addDays`, `startOfWeek`, …).
2063
+ **A repeating event costs one row, not a year of them:** put a `repeat` on the event
2064
+ (`{ every: "day" | "week" | "month", interval?, on?, until?, count? }`) and the calendar
2065
+ expands it to the occurrences in the view's own range. Deliberately NOT RFC 5545 — a
2066
+ timetable, a standup, a monthly close; see `expandRepeats` for why the long tail is a
2067
+ platform field type rather than a wider rule. An occurrence's id encodes its source, so
2068
+ `onEventPress` still hands you the row the series came from (`sourceEventId`).
2069
+ **There is no `timeZone` prop, on purpose:** Lotics stores a datetime as a naive WALL CLOCK,
2070
+ so "14:30" means 14:30 to the business and there is nothing to convert — re-projecting it
2071
+ would move every event. What does depend on where the reader sits is *what day it is*, so
2072
+ that alone is the `now` prop (which also pins "today" in a test).
2073
+ Three things it decides for you: **which shape** — below `compactBelow` (720px) or too short
2074
+ for a month to name one event per row, month/week become the agenda, because seven columns on
2075
+ a phone can only say WHICH days have something; **how many lanes** — measured against the row,
2076
+ not a constant; and **the hour window** — `dayStartHour`/`dayEndHour` are a VIEWPORT, so a
2077
+ 06:00 delivery widens the grid instead of disappearing from it. `color` is a `ColorName`, not
2078
+ a hex. Labels resolve prop → `LoticsLocale.calendarView` → English.
2079
+ - **`gantt`** — `GanttView`: a frozen label column beside a zoomable, horizontally
2080
+ scrollable axis. Reach for it over a calendar when the **rows** are the subject (a phase, an
2081
+ owner, a vehicle, a channel) and dates are the horizontal axis. **Lanes** come from `group`,
2082
+ **milestones** render as diamonds, and `progress` fills part of a bar. No dependency
2083
+ connectors: lane order and bar position already carry the sequence, and elbow arrows over an
2084
+ already-ruled grid restate it as line noise. The bar carries no label, because the frozen
2085
+ column beside it already names the task. Helpers: `buildRows`, `barGeometry`, `axisRange`,
2086
+ `buildTicks`, `pxPerDay`; types `GanttTask`/`GanttRow`/`GanttLabels`. `color` is a
2087
+ `ColorName`; labels resolve prop → `LoticsLocale.gantt` → English.
2059
2088
  - **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
2060
2089
  details; models the past, NOT progress. Three things it decides for you, because a row
2061
2090
  cannot be trusted to a caller's data: the **label clamps to two lines WHILE COLLAPSED**
@@ -360,6 +360,33 @@ badge. Every period-dependent number MUST follow the selection. Pass `includeTim
360
360
  time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
361
361
  translates the presets, footer buttons, placeholder, and the time-segment editors.
362
362
 
363
+ ## A calendar answers WHEN — and only when the position on a timeline is the question
364
+
365
+ A calendar is not a prettier register. Reach for one when the reader's question is *when does
366
+ this happen* and the answer is read off a POSITION — which day, how long, what else is running
367
+ at the same time. When the question is *which of these needs me*, the answer is a register with
368
+ a date column, and a calendar buries it: thirty rows of a table are scannable, thirty rows
369
+ scattered across a month grid are a search.
370
+
371
+ Three rules follow from that, and each of them is about legibility rather than taste.
372
+
373
+ **A grid is unreadable in two directions.** Seven equal columns need roughly 110px each to hold
374
+ a real title; below that every event reads "Cont…" and the grid can only say WHICH days have
375
+ something. Height fails the same way — a month too short to give each week row one event plus
376
+ its overflow chip renders nothing but "+3 more" on all thirty-one days. Both are the same
377
+ finding, and the answer to both is a different SHAPE: `CalendarView` falls back to the agenda on
378
+ its own. Never wrap a month grid in a horizontal scroller to force it — that trades a readable
379
+ list for a wide thing the reader has to drag.
380
+
381
+ **A time window is a viewport, never a filter.** Declaring the working day as 08:00–18:00 sets
382
+ where the grid OPENS. An event outside it widens the grid; a calendar that silently refuses to
383
+ draw a 06:00 delivery is a calendar that lies, and nothing on screen says so.
384
+
385
+ **Rows of days, or rows of resources — pick by what the reader is comparing.** Comparing days
386
+ is a calendar. Comparing a channel against a channel, an owner against an owner, a vehicle
387
+ against a vehicle — with days as the horizontal axis — is `GanttView`. A campaign plan is
388
+ usually the second one, however much it is called a calendar.
389
+
363
390
  ## Keyboard & focus
364
391
 
365
392
  - **Use `tabIndex`, never `focusable`.** React Native Web's `Pressable` silently ignores
@@ -1827,6 +1854,31 @@ the ring. Collapsing them into one "active" style makes the selected item pixel-
1827
1854
  hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
1828
1855
  is a single boolean OR-ing selection together with hover, press and focus.
1829
1856
 
1857
+ ## A body, not an edge — and a corner proportionate to the box
1858
+
1859
+ Two rules that decide how any small coloured surface is drawn: an event chip, a board card, a
1860
+ timeline bar, a status pill.
1861
+
1862
+ **A wash gives it a body; a border on top is the same edge asserted twice.** A transparent
1863
+ rectangle on a white canvas *is* its border — so the fill is what makes it an object, and once
1864
+ it has one, the outline says nothing new. This matters most where the surface already sits on a
1865
+ ruled ground (a calendar grid, a timeline, a table): every extra hairline competes with the
1866
+ rules that carry the structure. The same goes for an accent stripe down one side — it is a
1867
+ border wearing a colour, and the wash it sits on has already stated where the thing begins.
1868
+
1869
+ **The wash is the family's light ground with its dark ink, never the solid shade with white on
1870
+ it.** A saturated fill per item turns a dense surface into a paint chart, and a screen with a
1871
+ dozen categories on it stops being something anyone can look at. The pairing is the one `Badge`
1872
+ uses for `tonal`. The solid shade keeps exactly one job: a DOT — a few pixels carrying identity
1873
+ rather than area, where the contrast is the point.
1874
+
1875
+ **A radius is a proportion of the box, not a constant.** A component whose height is data —
1876
+ a chip sized to its duration, a bar sized to its span — cannot take one number: at 22px the
1877
+ control rung renders a pill, and the same value on a tall block is barely a corner, so two
1878
+ boxes of one family read as two vocabularies. Scale by height and cap at the container rung
1879
+ (`proportionalRadius`). Keep the ratio under a third, or the corner eats the side and the shape
1880
+ stops being a rounded rectangle.
1881
+
1830
1882
  ## `Badge` is for STATUS only — everything else is text
1831
1883
 
1832
1884
  A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
package/docs/reviewing.md CHANGED
@@ -397,7 +397,7 @@ A destination named for two things is two destinations, and the second strip is
397
397
  gets taken apart again. The same grep over the source (`<Tabs` per file) finds it without a
398
398
  running app: more than one file rendering `Tabs` in one app is the same defect at rest.
399
399
 
400
- → [composition.md](./composition.md) §"Choice, filter & navigation controls" (one strip per surface).
400
+ → [composition.md](./composition.md) §"One view-control vocabulary" (one strip per surface).
401
401
 
402
402
  ### 8b. Does each tab earn its slot — count its rows AS A SCOPED READER
403
403
 
@@ -417,7 +417,7 @@ permission check; the screen moves to the app its owner already opens.
417
417
  **Source-side signature:** a screen whose list query filters by the viewer's membership, over a
418
418
  table the domain describes as organisation-wide.
419
419
 
420
- → [composition.md](./composition.md) §"Choice, filter & navigation controls".
420
+ → [composition.md](./composition.md) §"One view-control vocabulary".
421
421
 
422
422
  ### 8c. First paint — count the controls the task actually needs
423
423
  - **On any screen whose job is ENTRY, count `input`/`select`/`textarea` on first paint.** Zero is
@@ -450,7 +450,7 @@ filter in its dependency array. Typecheck, lint and a screenshot all passed it.)
450
450
  **The cheap source-side signature:** a `SummaryLine` item carrying `tone` in a file whose filter
451
451
  band has only a `SearchInput`.
452
452
 
453
- → [composition.md](./composition.md) §"Choice, filter & navigation controls"; the rule itself is in
453
+ → [composition.md](./composition.md) §"One view-control vocabulary"; the rule itself is in
454
454
  `src/summary_line.tsx`'s contract.
455
455
 
456
456
  ### 8e. Derivable columns — every column another cell already answers
@@ -503,8 +503,9 @@ the aggregates no control states: the money, the extreme, the ratio.
503
503
  hook's array) where the register renders a filtered derivative of it — and the two sitting far
504
504
  apart in the file, which is what lets them drift.
505
505
 
506
- → [composition.md](./composition.md) §"Register / list" — the rule this measures is already
507
- written there; this probe exists because reading it is not the same as obeying it.
506
+ → [composition.md](./composition.md) §"Summaries two altitudes, never mixed" — the rule this
507
+ measures is already written there; this probe exists because reading it is not the same as obeying
508
+ it.
508
509
 
509
510
  ### 8g. Truncation — every string the layout cut, at the width a phone actually is
510
511
 
@@ -739,17 +740,34 @@ Snapshot the SAME selector four times — at rest, hovered, focused, and while e
739
740
  const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
740
741
  const inner = e.querySelector('input, textarea, [contenteditable]') || e;
741
742
  const ir = inner.getBoundingClientRect();
743
+ // The PAINTED node, which is often not the one you selected: a press target
744
+ // is frequently transparent with its ground on a child (a chip's body, a
745
+ // bar's fill). Reading the target's own `backgroundColor` returns
746
+ // `rgba(0,0,0,0)` at rest AND hovered, so a component with a perfectly good
747
+ // hover reports as having none — a probe that fails silently in the
748
+ // reassuring direction.
749
+ const opaque = (x) => { const b = getComputedStyle(x).backgroundColor; return b && b !== 'rgba(0, 0, 0, 0)' ? b : null; };
750
+ const painted = opaque(e) || [...e.querySelectorAll('*')].map(opaque).find(Boolean) || 'none';
742
751
  return {
743
752
  box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
744
753
  text: [Math.round(ir.x), Math.round(ir.y)],
745
754
  border: `${cs.borderTopWidth} ${cs.borderTopColor}`,
746
- bg: cs.backgroundColor,
755
+ bg: painted,
747
756
  shadow: cs.boxShadow,
748
757
  };
749
758
  })
750
759
  ```
751
760
 
752
- `border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect. The usual
761
+ `border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect.
762
+
763
+ **A control that differs in NOTHING is the finding this catches most often**, and it does not
764
+ look like a bug in a screenshot — the thing renders correctly, presses correctly, announces
765
+ correctly, and simply never says it can be pressed. Run it against anything the eye reads as a
766
+ card, chip or bar: identical rest and hover means the affordance was never built. What should
767
+ change is the surface's OWN signature (a chip's fill, a row's wash, a field's border) one rung
768
+ deeper, never a different colour and never the cursor — see composition.md §"The pointer
769
+ cursor". And a surface with no press handler must NOT light up: an affordance on something
770
+ inert advertises an action that does not exist. The usual
753
771
  cause is two components spending the same inset or the same padding — a wrapper AND the input both
754
772
  paying `CONTROL_TEXT_INSET`, a focus ring owned by both a shell and a nested pressable.
755
773
 
package/docs/templates.md CHANGED
@@ -1015,8 +1015,14 @@ when you want them in the context of a whole record surface, and here when money
1015
1015
  One real `CalendarView` week grid over the current week's events, then a "Today" agenda card
1016
1016
  of pressable rows. Every slot is a door: pressing a grid event or an agenda row opens the
1017
1017
  sequenced workspace `Drawer`; agenda rows also carry the `⋯` quick-actions menu. Event colors
1018
- carry meaning (one `ColorName` per event kind). The mock data anchors to the real current
1019
- week so the copied screen is evergreen.
1018
+ carry meaning (one `ColorName` per event kind, stated ONCE the chip's colour and the agenda
1019
+ row's swatch read the same field). The mock data anchors to the real current week so the copied
1020
+ screen is evergreen.
1021
+
1022
+ **Read the header band for the controlled pattern.** The screen owns `date`, so its own "Today"
1023
+ button sets it — the calendar has no second toolbar of its own to fight, and nothing is
1024
+ remounted to force a jump. That is the shape to copy whenever a calendar sits under a header
1025
+ band you already have.
1020
1026
 
1021
1027
  ### `tpl_attendance` — the attendance desk
1022
1028
 
@@ -1,7 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { colors, solid, type ColorName } from "@lotics/ui/colors";
4
+ import { colors, type ColorName } from "@lotics/ui/colors";
5
5
  import { ActionMenu } from "@lotics/ui/action_menu";
6
6
  import { Badge } from "@lotics/ui/badge";
7
7
  import { Button } from "@lotics/ui/button";
@@ -52,47 +52,48 @@ function evt(
52
52
  m: number,
53
53
  durMin: number,
54
54
  title: string,
55
- color: string,
56
55
  data: AgendaMeta,
57
56
  ): CalendarEvent<AgendaMeta> {
58
57
  const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, m);
59
- return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color, data };
58
+ // The chip's colour IS the row's colour `data.mau` is the one statement of
59
+ // it, so the two surfaces cannot drift apart.
60
+ return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color: data.mau, data };
60
61
  }
61
62
 
62
63
  const EVENTS: CalendarEvent<AgendaMeta>[] = [
63
64
  // Today — the dispatch desk's three slots.
64
- evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", solid("blue"), {
65
+ evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", {
65
66
  diaDiem: "Eastport industrial zone",
66
67
  trangThai: "Truck loaded",
67
68
  mau: "blue",
68
69
  }),
69
- evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", solid("emerald"), {
70
+ evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", {
70
71
  diaDiem: "Head office",
71
72
  trangThai: "Prepared",
72
73
  mau: "emerald",
73
74
  }),
74
- evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", solid("amber"), {
75
+ evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", {
75
76
  diaDiem: "Factory sample room",
76
77
  trangThai: "Awaiting confirmation",
77
78
  mau: "amber",
78
79
  }),
79
80
  // The rest of the week.
80
- evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", solid("blue"), {
81
+ evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", {
81
82
  diaDiem: "Northgate industrial park",
82
83
  trangThai: "Truck loaded",
83
84
  mau: "blue",
84
85
  }),
85
- evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", solid("emerald"), {
86
+ evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", {
86
87
  diaDiem: "Head office",
87
88
  trangThai: "Prepared",
88
89
  mau: "emerald",
89
90
  }),
90
- evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", solid("amber"), {
91
+ evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", {
91
92
  diaDiem: "Online meeting",
92
93
  trangThai: "Awaiting confirmation",
93
94
  mau: "amber",
94
95
  }),
95
- evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", solid("blue"), {
96
+ evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", {
96
97
  diaDiem: "Brightcell plant, Eastport",
97
98
  trangThai: "Truck loaded",
98
99
  mau: "blue",
@@ -104,20 +105,10 @@ const EVENTS: CalendarEvent<AgendaMeta>[] = [
104
105
  start: addDays(WEEK_START, 2),
105
106
  end: addDays(WEEK_START, 4),
106
107
  allDay: true,
107
- color: colors.zinc[500],
108
+ color: "zinc",
108
109
  },
109
110
  ];
110
111
 
111
- const CAL_LABELS = {
112
- today: "Today",
113
- month: "Month",
114
- week: "Week",
115
- day: "Day",
116
- previous: "Previous",
117
- next: "Next",
118
- allDay: "all day",
119
- more: (n: number) => `+${n} more`,
120
- };
121
112
 
122
113
  const p2 = (n: number) => String(n).padStart(2, "0");
123
114
  const fmtTime = (d: Date) => `${p2(d.getHours())}:${p2(d.getMinutes())}`;
@@ -187,9 +178,10 @@ function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
187
178
  }
188
179
 
189
180
  export function TplCalendar() {
190
- // "Today" remounts the calendar CalendarView owns date+view internally,
191
- // so a fresh mount is the supported way to jump back to the current week.
192
- const [calKey, setCalKey] = useState(0);
181
+ // The screen owns the date, so its own header band can drive the calendar —
182
+ // no remount, no second toolbar. This used to be `key={calKey}`, forcing a
183
+ // fresh mount because the calendar's date was private to it.
184
+ const [date, setDate] = useState(TODAY);
193
185
  const [openId, setOpenId] = useState<string | null>(null);
194
186
 
195
187
  const openEvent = EVENTS.find((e) => e.id === openId) ?? null;
@@ -215,21 +207,22 @@ export function TplCalendar() {
215
207
  <Text size="sm" color="muted">This week — deliveries, reconciliations and customer visits</Text>
216
208
  </View>
217
209
  <Text size="sm" color="muted" tabular>
218
- {viewTitle("week", TODAY, 1, "vi")}
210
+ {viewTitle("week", date, 1, "vi")}
219
211
  </Text>
220
- <Button title="Today" color="secondary" onPress={() => setCalKey((k) => k + 1)} />
212
+ <Button title="Today" color="secondary" onPress={() => setDate(TODAY)} />
221
213
  </View>
222
214
 
223
215
  {/* the calendar — real week grid, internal toolbar + scroll */}
224
216
  <Card style={{ padding: 0, height: 520, overflow: "hidden" }}>
225
217
  <CalendarView<AgendaMeta>
226
- key={calKey}
227
218
  events={EVENTS}
228
219
  defaultView="week"
229
- defaultDate={TODAY}
220
+ date={date}
221
+ onDateChange={setDate}
230
222
  weekStartsOn={1}
231
223
  locale="vi"
232
- labels={CAL_LABELS}
224
+ dayStartHour={7}
225
+ dayEndHour={19}
233
226
  onEventPress={(e) => setOpenId(e.id)}
234
227
  />
235
228
  </Card>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.14.0",
3
+ "version": "47.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
package/src/board.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { Platform, ScrollView, StyleSheet, View } from "react-native";
12
12
  import { colors, tint } from "./colors";
13
- import { CONTROL_RADIUS, MIN_CONTROL_WIDTH } from "./control_surface";
13
+ import { CONTROL_RADIUS, MIN_CONTROL_WIDTH, CARD_RADIUS } from "./control_surface";
14
14
  import { EmptyState } from "./empty_state";
15
15
  import { Icon } from "./icon";
16
16
  import { IconButton } from "./icon_button";
@@ -40,7 +40,6 @@ import { usePointerDrag } from "./use_pointer_drag";
40
40
  /** A card is a container, so it sits on the container rung of the radius ladder
41
41
  * — the same 16 a `Card` takes. Kept here rather than imported so the two
42
42
  * cannot drift apart silently; if the ladder moves, both move. */
43
- const CARD_RADIUS = 16;
44
43
 
45
44
  /** Half the difference between an `sm` IconButton's box (24) and its glyph (14).
46
45
  * It exists so a control cluster can be pulled out by exactly the amount that
@@ -0,0 +1,136 @@
1
+ import { useMemo } from "react";
2
+ import { ScrollView, View, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors, solid } from "../colors";
5
+ import { CARD_RADIUS } from "../control_surface";
6
+ import { ListItem } from "../list_item";
7
+ import { useCalendar } from "./context";
8
+ import { compareEvents, isBanner } from "./layout";
9
+ import { dayDiff, dayHeading, daysInView, formatTime, isToday, startOfDay } from "./dates";
10
+ import type { CalendarEvent } from "./types";
11
+
12
+ /**
13
+ * The agenda: days as headings, their events as rows.
14
+ *
15
+ * **This is the narrow-screen answer, and it is a different SHAPE rather than a
16
+ * smaller month grid.** Seven equal columns on a 375px screen leave 53px per
17
+ * day for titles that need 160 — every event reads "Cont…" and the calendar can
18
+ * only tell you WHICH days have something, never what. Below `compactBelow` the
19
+ * root mounts this instead, and the one app that hit this had already reached
20
+ * the same conclusion by hand, wrapping the grid in a horizontal scroller with
21
+ * a 760px floor.
22
+ *
23
+ * Empty days are omitted. A month is mostly empty days, and printing thirty
24
+ * "nothing scheduled" rows to find four with content is not a list, it is a
25
+ * haystack.
26
+ */
27
+ export function CalendarAgenda() {
28
+ const { events, date, weekStartsOn, locale, labels, now, onEventPress } = useCalendar();
29
+ const days = useMemo(() => daysInView("agenda", date, weekStartsOn), [date, weekStartsOn]);
30
+
31
+ const grouped = useMemo(() => {
32
+ const first = days[0];
33
+ const byDay = days.map((day) => ({
34
+ day,
35
+ items: events
36
+ .filter((e) => {
37
+ const from = dayDiff(startOfDay(e.start), day);
38
+ const to = dayDiff(day, startOfDay(e.end ?? e.start));
39
+ if (from < 0 || to < 0) return false;
40
+ // A multi-day banner is listed ONCE, on the day it starts — or on the
41
+ // first day of the range when it started before it, so something
42
+ // already running is still visible. Repeating it per day put a
43
+ // 28-day campaign on twenty-eight rows and buried everything that
44
+ // only happens once.
45
+ if (!isBanner(e)) return true;
46
+ const startsHere = startOfDay(e.start).getTime() === day.getTime();
47
+ const ongoingAtOpen =
48
+ day.getTime() === first.getTime() && startOfDay(e.start).getTime() < first.getTime();
49
+ return startsHere || ongoingAtOpen;
50
+ })
51
+ .sort(compareEvents),
52
+ }));
53
+ return byDay.filter((g) => g.items.length > 0);
54
+ }, [events, days]);
55
+
56
+
57
+ if (grouped.length === 0) {
58
+ return (
59
+ <View style={styles.empty}>
60
+ <Text size="sm" color="muted">{labels.noEvents}</Text>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <ScrollView style={styles.root} contentContainerStyle={styles.content}>
67
+ {grouped.map(({ day, items }) => {
68
+ const today = isToday(day, now);
69
+ return (
70
+ <View key={day.toISOString()} style={styles.group}>
71
+ <View style={styles.heading}>
72
+ <Text size="sm" weight={today ? "semibold" : "medium"} style={today ? styles.todayInk : undefined}>
73
+ {dayHeading(day, locale)}
74
+ </Text>
75
+ {today ? <View style={styles.todayDot} /> : null}
76
+ </View>
77
+ <View style={styles.card}>
78
+ {items.map((e) => (
79
+ <ListItem
80
+ key={e.id}
81
+ left={<View style={[styles.dot, { backgroundColor: solid(e.color ?? "teal") }]} />}
82
+ title={e.title}
83
+ description={describe(e, labels.allDay, locale)}
84
+ onPress={onEventPress ? () => onEventPress(e.id) : undefined}
85
+ />
86
+ ))}
87
+ </View>
88
+ </View>
89
+ );
90
+ })}
91
+ </ScrollView>
92
+ );
93
+ }
94
+
95
+ /** The row's second line: when it is, then whatever the event carries. Never
96
+ * the title again — the row above it already said that. */
97
+ function describe(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
98
+ const when = isBanner(event)
99
+ ? bannerSpan(event, allDayLabel, locale)
100
+ : event.end
101
+ ? `${formatTime(event.start, locale)} – ${formatTime(event.end, locale)}`
102
+ : formatTime(event.start, locale);
103
+ return event.meta ? `${when} · ${event.meta}` : when;
104
+ }
105
+
106
+ /** A banner listed once has to say how far it runs, or the row claims the whole
107
+ * thing happens on the day it is filed under. */
108
+ function bannerSpan(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
109
+ const end = event.end ? startOfDay(event.end) : null;
110
+ if (!end || dayDiff(event.start, end) <= 0) return allDayLabel;
111
+ const md = (d: Date) => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(d);
112
+ return `${md(event.start)} – ${md(end)}`;
113
+ }
114
+
115
+ const styles = StyleSheet.create({
116
+ root: { flex: 1, backgroundColor: colors.white },
117
+ content: { paddingBottom: 16 },
118
+ group: { paddingTop: 0, paddingHorizontal: 8 },
119
+ card: { borderRadius: CARD_RADIUS, overflow: "hidden", backgroundColor: colors.zinc[50] },
120
+ // A day heading is separated by SPACE and weight, not a rule — the rule was
121
+ // a second statement of a boundary the gap already makes.
122
+ heading: {
123
+ flexDirection: "row",
124
+ alignItems: "center",
125
+ gap: 6,
126
+ paddingHorizontal: 14,
127
+ paddingTop: 16,
128
+ paddingBottom: 6,
129
+ },
130
+ todayInk: { color: colors.teal[700] },
131
+ todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.teal[600] },
132
+ // A dot, not a bar down the row's left edge: an edge is a border, and the
133
+ // row's own wash already states where it begins.
134
+ dot: { width: 8, height: 8, borderRadius: 4 },
135
+ empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.white },
136
+ });