@lotics/ui 47.13.1 → 47.15.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 +17 -3
- package/docs/catalog.md +228 -29
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +13 -6
- package/docs/reviewing.md +71 -0
- package/docs/templates.md +1 -1
- package/examples/tpl_attendance.tsx +51 -80
- package/examples/tpl_pivot.tsx +10 -1
- package/examples/tpl_record.tsx +33 -107
- package/package.json +9 -1
- package/src/avatar_size.ts +9 -8
- package/src/axis_ticks.ts +63 -0
- package/src/bar_chart.tsx +69 -50
- package/src/calendar/calendar_view.tsx +8 -2
- package/src/cell_stack.tsx +43 -7
- package/src/chip_group.tsx +12 -6
- package/src/danger_zone.tsx +23 -10
- package/src/date_filter.tsx +17 -7
- package/src/date_filter_presets.ts +209 -1
- package/src/date_range_filter_field.tsx +14 -4
- package/src/detail_row.tsx +18 -18
- package/src/drawer.tsx +66 -28
- package/src/format_date.ts +31 -3
- package/src/gantt/gantt_view.tsx +6 -2
- package/src/index.css +18 -0
- package/src/ledger.tsx +23 -15
- package/src/line_chart.tsx +194 -61
- package/src/list_item.tsx +22 -3
- package/src/locale.tsx +25 -0
- package/src/matrix.tsx +116 -16
- package/src/matrix_totals.ts +71 -3
- package/src/month_math.ts +16 -0
- package/src/month_stepper.tsx +62 -0
- package/src/organization_name.ts +94 -0
- package/src/search_match.ts +19 -0
- package/src/section_nav.tsx +163 -0
- package/src/state_matrix.tsx +265 -0
- package/src/table.tsx +67 -5
- package/src/timetable.tsx +167 -0
- package/src/toggle_strip.tsx +259 -0
- package/src/vite.d.mts +7 -0
- package/src/vite.mjs +47 -15
package/src/cell_stack.tsx
CHANGED
|
@@ -11,6 +11,12 @@ export interface CellStackProps {
|
|
|
11
11
|
*
|
|
12
12
|
* One, not several: a stack of three is no longer a value and its annotation,
|
|
13
13
|
* it is a row that wants to be a row.
|
|
14
|
+
*
|
|
15
|
+
* To RESERVE the line on a row that has nothing to say — which is how a column
|
|
16
|
+
* whose cells are sometimes two lines keeps one first-line height down the page
|
|
17
|
+
* (probe 8h-bis) — pass a whitespace string. `" "` is what everybody writes and
|
|
18
|
+
* the web renderer collapses it, so it is normalised to a non-breaking space
|
|
19
|
+
* here rather than in twelve call sites.
|
|
14
20
|
*/
|
|
15
21
|
caption?: ReactNode;
|
|
16
22
|
/**
|
|
@@ -24,6 +30,13 @@ export interface CellStackProps {
|
|
|
24
30
|
* does, so the caption keeps body size beside one.
|
|
25
31
|
*/
|
|
26
32
|
marked?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The identity mark, drawn HERE — an `Avatar`, a `FileBadge`. Sets `marked`
|
|
35
|
+
* and owns the gap between the mark and the pair, so a register's subject
|
|
36
|
+
* column is not one more hand-rolled row of mark + stack with its own idea
|
|
37
|
+
* of the gap. The column still budgets the mark's width (`TableColumn.lead`).
|
|
38
|
+
*/
|
|
39
|
+
leading?: ReactNode;
|
|
27
40
|
/** The subject's rung. The pair takes ONE rung, so the caption follows it. */
|
|
28
41
|
size?: TextSize;
|
|
29
42
|
/** The subject's ink. The caption is always `muted` — it is supporting text. */
|
|
@@ -80,7 +93,8 @@ export function CellStack(props: CellStackProps) {
|
|
|
80
93
|
title,
|
|
81
94
|
caption,
|
|
82
95
|
weight = "regular",
|
|
83
|
-
marked = false,
|
|
96
|
+
marked: markedProp = false,
|
|
97
|
+
leading,
|
|
84
98
|
size = "sm",
|
|
85
99
|
color,
|
|
86
100
|
tabular,
|
|
@@ -90,7 +104,16 @@ export function CellStack(props: CellStackProps) {
|
|
|
90
104
|
testID,
|
|
91
105
|
} = props;
|
|
92
106
|
|
|
93
|
-
|
|
107
|
+
// A caption reserving the line with nothing in it. `" "` says "keep this line"
|
|
108
|
+
// unambiguously and collapses in the web renderer, so the reserved line loses
|
|
109
|
+
// its height and the cell drops to ONE line — while the row height, set by
|
|
110
|
+
// whichever sibling is two lines tall, never moves. The column then puts its
|
|
111
|
+
// first line at two different heights on rows that look identical to every
|
|
112
|
+
// spacing probe. One app wrote it twelve times.
|
|
113
|
+
const line = typeof caption === "string" && caption.length > 0 && caption.trim() === "" ? NBSP : caption;
|
|
114
|
+
const hasCaption = line !== undefined && line !== null && line !== false;
|
|
115
|
+
const hasLeading = leading !== undefined && leading !== null && leading !== false;
|
|
116
|
+
const marked = markedProp || hasLeading;
|
|
94
117
|
// Rule 1 — the caption's rung, from what else is already separating the pair.
|
|
95
118
|
const captionSize: TextSize = weight !== "regular" || marked ? size : "xs";
|
|
96
119
|
// Rule 2 — tight closes a PAIR; `xs` is tight by construction, and a lone line
|
|
@@ -98,8 +121,8 @@ export function CellStack(props: CellStackProps) {
|
|
|
98
121
|
const subjectTight = hasCaption ? "tight" : undefined;
|
|
99
122
|
const captionTight = captionSize === "xs" ? undefined : "tight";
|
|
100
123
|
|
|
101
|
-
|
|
102
|
-
<View style={[{ gap: 0, alignItems: ALIGN[align] }, style]} testID={testID}>
|
|
124
|
+
const stack = (
|
|
125
|
+
<View style={[{ gap: 0, alignItems: ALIGN[align] }, hasLeading ? { flex: 1, minWidth: 0 } : style]} testID={hasLeading ? undefined : testID}>
|
|
103
126
|
{typeof title === "string" || typeof title === "number" ? (
|
|
104
127
|
<Text
|
|
105
128
|
size={size}
|
|
@@ -115,7 +138,7 @@ export function CellStack(props: CellStackProps) {
|
|
|
115
138
|
title
|
|
116
139
|
)}
|
|
117
140
|
{hasCaption
|
|
118
|
-
? typeof
|
|
141
|
+
? typeof line === "string" || typeof line === "number"
|
|
119
142
|
? (
|
|
120
143
|
<Text
|
|
121
144
|
size={captionSize}
|
|
@@ -124,15 +147,28 @@ export function CellStack(props: CellStackProps) {
|
|
|
124
147
|
tabular={tabular}
|
|
125
148
|
numberOfLines={numberOfLines}
|
|
126
149
|
>
|
|
127
|
-
{
|
|
150
|
+
{line}
|
|
128
151
|
</Text>
|
|
129
152
|
)
|
|
130
|
-
:
|
|
153
|
+
: line
|
|
131
154
|
: null}
|
|
132
155
|
</View>
|
|
133
156
|
);
|
|
157
|
+
if (!hasLeading) return stack;
|
|
158
|
+
return (
|
|
159
|
+
<View style={[{ flexDirection: "row", alignItems: "center", gap: LEADING_GAP, minWidth: 0 }, style]} testID={testID}>
|
|
160
|
+
{leading}
|
|
161
|
+
{stack}
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
134
164
|
}
|
|
135
165
|
|
|
166
|
+
const NBSP = "\u00a0";
|
|
167
|
+
|
|
168
|
+
/** Mark to pair \u2014 the ONE gap between an identity mark and the label beside it,
|
|
169
|
+
* wherever the kit draws that pair (`CellStack`, `Matrix`, `StateMatrix`). */
|
|
170
|
+
export const LEADING_GAP = 10;
|
|
171
|
+
|
|
136
172
|
const ALIGN = {
|
|
137
173
|
left: "flex-start",
|
|
138
174
|
right: "flex-end",
|
package/src/chip_group.tsx
CHANGED
|
@@ -147,12 +147,18 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
|
|
|
147
147
|
color={option.iconColor ?? (active ? colors.zinc[900] : colors.zinc[500])}
|
|
148
148
|
/>
|
|
149
149
|
) : null}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
{/* MEDIUM on both, never semibold on the active one.
|
|
151
|
+
|
|
152
|
+
A chip already answers "which one is on" twice — a doubled
|
|
153
|
+
zinc-900 edge and a ring — so the weight was a third signal, and
|
|
154
|
+
the wrong kind: semibold is a separate, genuinely heavier FILE
|
|
155
|
+
and the heading ramp is semibold at every rung, so an active chip
|
|
156
|
+
rendered a filter value at heading weight. State steps up in INK,
|
|
157
|
+
which is what `color` does here. It also made the active chip a
|
|
158
|
+
singleton treatment on every screen carrying one, while the
|
|
159
|
+
sibling that answers the same question — `Tabs` — steps
|
|
160
|
+
regular → medium. One question, one answer. */}
|
|
161
|
+
<Text userSelect="none" size="sm" weight="medium" color={active ? "default" : "muted"}>
|
|
156
162
|
{option.label}
|
|
157
163
|
</Text>
|
|
158
164
|
{option.count != null ? (
|
package/src/danger_zone.tsx
CHANGED
|
@@ -23,24 +23,32 @@ export interface DangerZoneProps {
|
|
|
23
23
|
* a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
|
|
24
24
|
* discipline, never a raw hex); the consequence goes in `description`, and the
|
|
25
25
|
* action(s) go in `children`.
|
|
26
|
+
*
|
|
27
|
+
* ONE LEFT EDGE, and it is `Callout`'s anatomy that gets it: the icon sits in an
|
|
28
|
+
* outer ROW beside a column holding title, consequence and actions, so all three
|
|
29
|
+
* derive from the same edge. Put the icon INSIDE the heading row instead — the
|
|
30
|
+
* obvious way to write it — and only the TITLE moves right, so the loudest line
|
|
31
|
+
* in the block sits 22px past the sentence explaining it and the button under
|
|
32
|
+
* both starts somewhere else again. Three edges inside one 16px-padded box, on
|
|
33
|
+
* the one primitive that exists so a hazard is not hand-rolled.
|
|
26
34
|
*/
|
|
27
35
|
export function DangerZone(props: DangerZoneProps) {
|
|
28
36
|
const locale = useLoticsLocale();
|
|
29
37
|
const { title = locale.dangerZone.title, description, children } = props;
|
|
30
38
|
return (
|
|
31
39
|
<View style={styles.zone}>
|
|
32
|
-
<
|
|
33
|
-
|
|
40
|
+
<Icon name="triangle-alert" size={16} color={solid("red")} />
|
|
41
|
+
<View style={styles.body}>
|
|
34
42
|
<Text size="sm" weight="semibold" color="danger">
|
|
35
43
|
{title}
|
|
36
44
|
</Text>
|
|
45
|
+
{description ? (
|
|
46
|
+
<Text size="sm" color="muted">
|
|
47
|
+
{description}
|
|
48
|
+
</Text>
|
|
49
|
+
) : null}
|
|
50
|
+
<View style={styles.actions}>{children}</View>
|
|
37
51
|
</View>
|
|
38
|
-
{description ? (
|
|
39
|
-
<Text size="sm" color="muted">
|
|
40
|
-
{description}
|
|
41
|
-
</Text>
|
|
42
|
-
) : null}
|
|
43
|
-
<View style={styles.actions}>{children}</View>
|
|
44
52
|
</View>
|
|
45
53
|
);
|
|
46
54
|
}
|
|
@@ -49,13 +57,18 @@ const styles = StyleSheet.create({
|
|
|
49
57
|
// A soft danger frame: a low-alpha red hairline over the faintest red wash —
|
|
50
58
|
// the "set apart, not shouting" treatment via the kit's tint discipline.
|
|
51
59
|
zone: {
|
|
52
|
-
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
alignItems: "flex-start",
|
|
62
|
+
gap: 10,
|
|
53
63
|
padding: 16,
|
|
54
64
|
borderRadius: 10,
|
|
55
65
|
borderWidth: 1,
|
|
56
66
|
borderColor: tint("red", 0.3),
|
|
57
67
|
backgroundColor: tint("red", 0.03),
|
|
58
68
|
},
|
|
59
|
-
|
|
69
|
+
// The column every line in the block starts from — 1px down so the first line
|
|
70
|
+
// of type sits optically level with the 16px mark beside it, exactly as
|
|
71
|
+
// `Callout` does it.
|
|
72
|
+
body: { flex: 1, gap: 8, paddingTop: 1 },
|
|
60
73
|
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
|
|
61
74
|
});
|
package/src/date_filter.tsx
CHANGED
|
@@ -33,6 +33,10 @@ export interface DateFilterLabels extends SegmentLabels {
|
|
|
33
33
|
thisWeek: string;
|
|
34
34
|
thisMonth: string;
|
|
35
35
|
lastMonth: string;
|
|
36
|
+
thisQuarter: string;
|
|
37
|
+
lastQuarter: string;
|
|
38
|
+
thisYear: string;
|
|
39
|
+
lastYear: string;
|
|
36
40
|
from: string;
|
|
37
41
|
to: string;
|
|
38
42
|
/** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
|
|
@@ -71,6 +75,14 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
|
|
|
71
75
|
return labels.thisMonth;
|
|
72
76
|
case "last_month":
|
|
73
77
|
return labels.lastMonth;
|
|
78
|
+
case "this_quarter":
|
|
79
|
+
return labels.thisQuarter;
|
|
80
|
+
case "last_quarter":
|
|
81
|
+
return labels.lastQuarter;
|
|
82
|
+
case "this_year":
|
|
83
|
+
return labels.thisYear;
|
|
84
|
+
case "last_year":
|
|
85
|
+
return labels.lastYear;
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -224,12 +236,10 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
224
236
|
(id: PresetId) => {
|
|
225
237
|
// A preset replaces the whole range, so any half-picked start is abandoned.
|
|
226
238
|
setPendingStart(null);
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
239
|
+
// Resolved against today on EVERY press, the active one included: a preset
|
|
240
|
+
// names a period, so re-pressing it re-anchors a range that has gone stale
|
|
241
|
+
// (a panel left open across midnight) rather than doing nothing. Clearing
|
|
242
|
+
// is the footer's Clear button — this list holds no null case.
|
|
233
243
|
const presetValue = getPresetValue(id, new Date());
|
|
234
244
|
onValueChange(presetValue);
|
|
235
245
|
|
|
@@ -240,7 +250,7 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
240
250
|
);
|
|
241
251
|
}
|
|
242
252
|
},
|
|
243
|
-
[onValueChange
|
|
253
|
+
[onValueChange],
|
|
244
254
|
);
|
|
245
255
|
|
|
246
256
|
// `selected` + `role="option"` is the kit's listbox row, and `MenuButton` owns
|
|
@@ -10,7 +10,11 @@ export type PresetId =
|
|
|
10
10
|
| "tomorrow"
|
|
11
11
|
| "this_week"
|
|
12
12
|
| "this_month"
|
|
13
|
-
| "last_month"
|
|
13
|
+
| "last_month"
|
|
14
|
+
| "this_quarter"
|
|
15
|
+
| "last_quarter"
|
|
16
|
+
| "this_year"
|
|
17
|
+
| "last_year";
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Display order. Every id here SETS a range — that is what makes the list a set
|
|
@@ -22,6 +26,9 @@ export type PresetId =
|
|
|
22
26
|
* the obvious move for "let me pick my own dates", threw the range away. The
|
|
23
27
|
* calendar above it is the custom picker, and the panel's Clear button is the
|
|
24
28
|
* clear; the item was a third name for two controls that were already there.
|
|
29
|
+
*
|
|
30
|
+
* Ordered by GRAIN, shortest first — a reader scanning for "this quarter"
|
|
31
|
+
* looks past the days and the month rather than hunting an alphabetical list.
|
|
25
32
|
*/
|
|
26
33
|
export const PRESET_IDS: PresetId[] = [
|
|
27
34
|
"today",
|
|
@@ -30,6 +37,10 @@ export const PRESET_IDS: PresetId[] = [
|
|
|
30
37
|
"this_week",
|
|
31
38
|
"this_month",
|
|
32
39
|
"last_month",
|
|
40
|
+
"this_quarter",
|
|
41
|
+
"last_quarter",
|
|
42
|
+
"this_year",
|
|
43
|
+
"last_year",
|
|
33
44
|
];
|
|
34
45
|
|
|
35
46
|
function startOfDay(date: Date): Date {
|
|
@@ -73,6 +84,27 @@ function range(start: Date, end: Date): DateFilterValue {
|
|
|
73
84
|
return { start: { date: start, time: null }, end: { date: end, time: null } };
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
/** The 0-based quarter a month falls in. */
|
|
88
|
+
function quarterOf(date: Date): number {
|
|
89
|
+
return Math.floor(date.getMonth() / 3);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The whole quarter `q` (0-based) of `year`, as a range.
|
|
94
|
+
*
|
|
95
|
+
* Built from the quarter INDEX rather than by shifting a date three months:
|
|
96
|
+
* `setMonth(getMonth() - 3)` on the 31st of a month lands in the month after
|
|
97
|
+
* the one meant (31 May → 31 February → 3 March), and the quarter then comes
|
|
98
|
+
* out one too late for every long month.
|
|
99
|
+
*/
|
|
100
|
+
function quarterRange(year: number, q: number): DateFilterValue {
|
|
101
|
+
return range(new Date(year, q * 3, 1), new Date(year, q * 3 + 3, 0, 23, 59, 59, 999));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function yearRange(year: number): DateFilterValue {
|
|
105
|
+
return range(new Date(year, 0, 1), new Date(year, 11, 31, 23, 59, 59, 999));
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
/**
|
|
77
109
|
* Resolve a preset to a concrete date range relative to `now`. Total — every
|
|
78
110
|
* `PresetId` names a range. The boundary math is identical to the view-page
|
|
@@ -101,5 +133,181 @@ export function getPresetValue(id: PresetId, now: Date): DateFilterValue {
|
|
|
101
133
|
d.setMonth(d.getMonth() - 1);
|
|
102
134
|
return range(startOfMonth(d), endOfMonth(d));
|
|
103
135
|
}
|
|
136
|
+
case "this_quarter":
|
|
137
|
+
return quarterRange(now.getFullYear(), quarterOf(now));
|
|
138
|
+
case "last_quarter": {
|
|
139
|
+
const q = quarterOf(now) - 1;
|
|
140
|
+
return q < 0 ? quarterRange(now.getFullYear() - 1, 3) : quarterRange(now.getFullYear(), q);
|
|
141
|
+
}
|
|
142
|
+
case "this_year":
|
|
143
|
+
return yearRange(now.getFullYear());
|
|
144
|
+
case "last_year":
|
|
145
|
+
return yearRange(now.getFullYear() - 1);
|
|
104
146
|
}
|
|
105
147
|
}
|
|
148
|
+
|
|
149
|
+
const DAY_MS = 86_400_000;
|
|
150
|
+
|
|
151
|
+
function isWholeMonth(start: Date, end: Date): boolean {
|
|
152
|
+
return (
|
|
153
|
+
start.getDate() === 1 &&
|
|
154
|
+
start.getFullYear() === end.getFullYear() &&
|
|
155
|
+
start.getMonth() === end.getMonth() &&
|
|
156
|
+
end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate()
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isWholeQuarter(start: Date, end: Date): boolean {
|
|
161
|
+
return (
|
|
162
|
+
start.getDate() === 1 &&
|
|
163
|
+
start.getMonth() % 3 === 0 &&
|
|
164
|
+
start.getFullYear() === end.getFullYear() &&
|
|
165
|
+
end.getMonth() === start.getMonth() + 2 &&
|
|
166
|
+
end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate()
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isWholeYear(start: Date, end: Date): boolean {
|
|
171
|
+
return (
|
|
172
|
+
start.getFullYear() === end.getFullYear() &&
|
|
173
|
+
start.getMonth() === 0 &&
|
|
174
|
+
start.getDate() === 1 &&
|
|
175
|
+
end.getMonth() === 11 &&
|
|
176
|
+
end.getDate() === 31
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* THE COMPARATOR for a selected range — the period a report means by "so với kỳ
|
|
182
|
+
* trước". Derived from the range the reader already picked, so a dashboard needs
|
|
183
|
+
* no second control beside its date field to say what it is comparing against.
|
|
184
|
+
*
|
|
185
|
+
* A range that IS a whole calendar month, quarter or year steps back one WHOLE
|
|
186
|
+
* period, never a fixed number of days: February against March is 28 days
|
|
187
|
+
* against 31, and sliding a 31-day window back would compare March against the
|
|
188
|
+
* last three days of January plus February. Any other complete range — a
|
|
189
|
+
* hand-picked fortnight, a week — steps back by its own LENGTH, ending the day
|
|
190
|
+
* before it starts, so two adjacent windows of equal size are compared.
|
|
191
|
+
*
|
|
192
|
+
* Whole days only. The bounds come back day-aligned and untimed, matching the
|
|
193
|
+
* presets, because a period comparison is a comparison of periods; a timed
|
|
194
|
+
* window has no previous one to speak of. `null` when either bound is missing —
|
|
195
|
+
* an open range names no period, and a comparator invented for one would be a
|
|
196
|
+
* figure the reader never asked for.
|
|
197
|
+
*/
|
|
198
|
+
export function previousPeriod(value: DateFilterValue): DateFilterValue | null {
|
|
199
|
+
const { date: startDate } = value.start;
|
|
200
|
+
const { date: endDate } = value.end;
|
|
201
|
+
if (!startDate || !endDate) return null;
|
|
202
|
+
|
|
203
|
+
const start = startOfDay(startDate);
|
|
204
|
+
const end = startOfDay(endDate);
|
|
205
|
+
|
|
206
|
+
if (isWholeYear(start, end)) return yearRange(start.getFullYear() - 1);
|
|
207
|
+
if (isWholeQuarter(start, end)) {
|
|
208
|
+
const q = quarterOf(start) - 1;
|
|
209
|
+
return q < 0 ? quarterRange(start.getFullYear() - 1, 3) : quarterRange(start.getFullYear(), q);
|
|
210
|
+
}
|
|
211
|
+
if (isWholeMonth(start, end)) {
|
|
212
|
+
const d = new Date(start.getFullYear(), start.getMonth() - 1, 1);
|
|
213
|
+
return range(startOfMonth(d), endOfMonth(d));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Rounded, not floored: a range spanning a DST change is 23 or 25 hours short
|
|
217
|
+
// of a whole number of days, and a floor would silently drop one from it.
|
|
218
|
+
const days = Math.round((end.getTime() - start.getTime()) / DAY_MS) + 1;
|
|
219
|
+
const prevEnd = new Date(start);
|
|
220
|
+
prevEnd.setDate(prevEnd.getDate() - 1);
|
|
221
|
+
const prevStart = new Date(prevEnd);
|
|
222
|
+
prevStart.setDate(prevStart.getDate() - (days - 1));
|
|
223
|
+
return range(startOfDay(prevStart), endOfDay(prevEnd));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A selected period read against the clock, with its comparator already cut. */
|
|
227
|
+
export interface PeriodToDate {
|
|
228
|
+
/** The selection, ending at `now` while the period is still RUNNING. */
|
|
229
|
+
current: DateFilterValue;
|
|
230
|
+
/** `previousPeriod`, cut to the same elapsed days. `null` when the selection
|
|
231
|
+
* is open-ended and so names no period to step back from. */
|
|
232
|
+
previous: DateFilterValue | null;
|
|
233
|
+
/** `now` falls before the selection's last day. */
|
|
234
|
+
running: boolean;
|
|
235
|
+
/** Days of the selection already behind us — 0 for a period still ahead. */
|
|
236
|
+
elapsedDays: number;
|
|
237
|
+
/** Days the whole selection holds, elapsed or not. */
|
|
238
|
+
totalDays: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Whole days between two day-aligned dates, inclusive. Rounded for the reason
|
|
242
|
+
* `previousPeriod` rounds: a DST change makes a span 23 or 25 hours. */
|
|
243
|
+
function dayCount(from: Date, to: Date): number {
|
|
244
|
+
return Math.round((to.getTime() - from.getTime()) / DAY_MS) + 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function addDays(date: Date, days: number): Date {
|
|
248
|
+
const d = new Date(date);
|
|
249
|
+
d.setDate(d.getDate() + days);
|
|
250
|
+
return d;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A RUNNING period is compared TO DATE: both sides cut to the days that have
|
|
255
|
+
* actually elapsed.
|
|
256
|
+
*
|
|
257
|
+
* Six days of this month placed beside a whole previous month reads as a
|
|
258
|
+
* collapse that never happened, and it lands on the first figure anyone looks
|
|
259
|
+
* at. So the selection ends at `now` while it is still running, and the
|
|
260
|
+
* comparator `previousPeriod` names is cut to the same count from ITS first
|
|
261
|
+
* day — six days against the first six, a quarter's 68 days against the
|
|
262
|
+
* previous quarter's first 68. The cut is clamped to the previous period's own
|
|
263
|
+
* end, so thirty elapsed days of March compare against all 28 of February
|
|
264
|
+
* rather than running past it.
|
|
265
|
+
*
|
|
266
|
+
* A period already closed keeps both sides whole. A period entirely in the
|
|
267
|
+
* future has nothing elapsed: `current` holds its first day and `elapsedDays`
|
|
268
|
+
* is 0, so a caller can say it has not started instead of drawing a −100%.
|
|
269
|
+
*
|
|
270
|
+
* `null` when either bound is missing, matching `previousPeriod`.
|
|
271
|
+
*/
|
|
272
|
+
export function periodToDate(value: DateFilterValue, now: Date): PeriodToDate | null {
|
|
273
|
+
const { date: startDate } = value.start;
|
|
274
|
+
const { date: endDate } = value.end;
|
|
275
|
+
if (!startDate || !endDate) return null;
|
|
276
|
+
|
|
277
|
+
const from = startOfDay(startDate);
|
|
278
|
+
const to = startOfDay(endDate);
|
|
279
|
+
const today = startOfDay(now);
|
|
280
|
+
const totalDays = dayCount(from, to);
|
|
281
|
+
const whole = previousPeriod(value);
|
|
282
|
+
|
|
283
|
+
if (today.getTime() >= to.getTime()) {
|
|
284
|
+
return {
|
|
285
|
+
current: range(from, endOfDay(to)),
|
|
286
|
+
previous: whole,
|
|
287
|
+
running: false,
|
|
288
|
+
elapsedDays: totalDays,
|
|
289
|
+
totalDays,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const elapsedDays = today.getTime() < from.getTime() ? 0 : dayCount(from, today);
|
|
294
|
+
const previous = (() => {
|
|
295
|
+
const prevStart = whole?.start.date;
|
|
296
|
+
const prevEnd = whole?.end.date;
|
|
297
|
+
if (!prevStart || !prevEnd) return null;
|
|
298
|
+
const opens = startOfDay(prevStart);
|
|
299
|
+
const closes = startOfDay(prevEnd);
|
|
300
|
+
// `max(elapsed, 1)`: a period not yet begun still names one day on each
|
|
301
|
+
// side, so the pair has the same shape whichever side of `now` it sits.
|
|
302
|
+
const cut = addDays(opens, Math.max(elapsedDays, 1) - 1);
|
|
303
|
+
return range(opens, endOfDay(cut.getTime() > closes.getTime() ? closes : cut));
|
|
304
|
+
})();
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
current: range(from, endOfDay(elapsedDays === 0 ? from : today)),
|
|
308
|
+
previous,
|
|
309
|
+
running: true,
|
|
310
|
+
elapsedDays,
|
|
311
|
+
totalDays,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -63,20 +63,30 @@ function formatBound(date: Date | null, time: string | null, locale: string | un
|
|
|
63
63
|
/**
|
|
64
64
|
* Recognized whole periods display compactly — a range that IS a calendar
|
|
65
65
|
* month reads "Tháng 6 năm 2026" (sentence-cased via the locale), a whole
|
|
66
|
-
* year "2026", a single day one date.
|
|
67
|
-
* "start – end". Keeps the trigger scannable where
|
|
68
|
-
* period rhythm, not date pairs.
|
|
66
|
+
* quarter "Quý 2 năm 2026", a whole year "2026", a single day one date.
|
|
67
|
+
* Anything else falls back to "start – end". Keeps the trigger scannable where
|
|
68
|
+
* dashboards live in period rhythm, not date pairs.
|
|
69
69
|
*/
|
|
70
70
|
function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
|
|
71
71
|
if (start.toDateString() === end.toDateString()) return formatDate(start, { locale });
|
|
72
72
|
|
|
73
|
+
const lastDayOfEndMonth = new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
|
|
74
|
+
|
|
73
75
|
const wholeMonth =
|
|
74
76
|
start.getDate() === 1 &&
|
|
75
77
|
start.getMonth() === end.getMonth() &&
|
|
76
78
|
start.getFullYear() === end.getFullYear() &&
|
|
77
|
-
end.getDate() ===
|
|
79
|
+
end.getDate() === lastDayOfEndMonth;
|
|
78
80
|
if (wholeMonth) return formatDate(start, { format: "monthYear", locale });
|
|
79
81
|
|
|
82
|
+
const wholeQuarter =
|
|
83
|
+
start.getDate() === 1 &&
|
|
84
|
+
start.getMonth() % 3 === 0 &&
|
|
85
|
+
start.getFullYear() === end.getFullYear() &&
|
|
86
|
+
end.getMonth() === start.getMonth() + 2 &&
|
|
87
|
+
end.getDate() === lastDayOfEndMonth;
|
|
88
|
+
if (wholeQuarter) return formatDate(start, { format: "quarterYear", locale });
|
|
89
|
+
|
|
80
90
|
const wholeYear =
|
|
81
91
|
start.getFullYear() === end.getFullYear() &&
|
|
82
92
|
start.getMonth() === 0 &&
|
package/src/detail_row.tsx
CHANGED
|
@@ -167,32 +167,32 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
167
167
|
// thing on two surfaces; they may not say a fault two different ways.
|
|
168
168
|
const annotations = <FieldAnnotations description={description} warning={warning} error={error} flat={flat} />;
|
|
169
169
|
if (table?.stacked) {
|
|
170
|
-
// Stacked mode wears the
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
170
|
+
// Stacked mode wears the form LOOK — the label renders like a `FormField`
|
|
171
|
+
// label (medium, default ink), so a narrow record surface reads as one
|
|
172
|
+
// vocabulary with forms — but keeps the RECORD's reading order: label,
|
|
173
|
+
// value, then whatever has to be said about it.
|
|
174
|
+
//
|
|
175
|
+
// Not the form ORDER, which puts the description above the control. A form's
|
|
176
|
+
// description is guidance you need BEFORE typing; a record's qualifies a
|
|
177
|
+
// value that already exists, so ahead of it the reader gets "còn 116 ngày"
|
|
178
|
+
// before any date and "con số là cho cả hợp đồng" before any con số. Reading
|
|
179
|
+
// a caption before its subject is not one job per element, and it made one
|
|
180
|
+
// row read two ways at two widths — the horizontal branch has always put
|
|
181
|
+
// annotations UNDER the value.
|
|
182
|
+
//
|
|
183
|
+
// The annotations are the shared `FieldAnnotations` anatomy, the same one
|
|
184
|
+
// the horizontal branch renders, so a fault says itself one way at both
|
|
185
|
+
// widths — hand-rolled here they came out a rung larger and off the
|
|
186
|
+
// control's own text inset.
|
|
175
187
|
return (
|
|
176
188
|
<View style={styles.stackedRow}>
|
|
177
189
|
{/* Wraps, like the horizontal label — a stacked row has the FULL width
|
|
178
190
|
to spend, so clipping here would be gratuitous. */}
|
|
179
191
|
<Text weight="medium">{label}</Text>
|
|
180
|
-
{/* stacked wears the FORM grammar exactly: label, description,
|
|
181
|
-
control, warning, error (the `FormField` order) */}
|
|
182
|
-
{description != null ? <Text color="muted">{description}</Text> : null}
|
|
183
192
|
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
184
193
|
<View style={styles.value}>{children}</View>
|
|
185
194
|
</View>
|
|
186
|
-
{
|
|
187
|
-
<Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
|
|
188
|
-
{warning}
|
|
189
|
-
</Text>
|
|
190
|
-
) : null}
|
|
191
|
-
{error != null ? (
|
|
192
|
-
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
|
|
193
|
-
{error}
|
|
194
|
-
</Text>
|
|
195
|
-
) : null}
|
|
195
|
+
{annotations}
|
|
196
196
|
</View>
|
|
197
197
|
);
|
|
198
198
|
}
|