pacatui 0.1.11 → 0.1.13
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/package.json +1 -1
- package/src/components/Dashboard.tsx +176 -84
package/package.json
CHANGED
|
@@ -109,29 +109,53 @@ function WeeklyTimeChart({
|
|
|
109
109
|
}) {
|
|
110
110
|
const colors = theme.colors;
|
|
111
111
|
const chartColors = theme.projectColors;
|
|
112
|
-
|
|
113
|
-
const
|
|
112
|
+
// Braille chars filled bottom-up (both columns): ⠀ ⣀ ⣤ ⣶ ⣿
|
|
113
|
+
const brailleBlocks = [
|
|
114
|
+
String.fromCodePoint(0x2800), // 0/4 empty
|
|
115
|
+
String.fromCodePoint(0x28c0), // 1/4 dots 7,8
|
|
116
|
+
String.fromCodePoint(0x28e4), // 2/4 dots 3,6,7,8
|
|
117
|
+
String.fromCodePoint(0x28f6), // 3/4 dots 2,3,5,6,7,8
|
|
118
|
+
String.fromCodePoint(0x28ff), // 4/4 all dots
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// Available width after parent padding (2 per side)
|
|
122
|
+
const availableWidth = Math.max(width - 4, 16);
|
|
123
|
+
const chartHeight = 8; // character rows for bar area (8 × 4 = 32 levels)
|
|
124
|
+
const gap = 1;
|
|
125
|
+
const minBarWidth = 2;
|
|
126
|
+
const maxBarWidth = 10;
|
|
127
|
+
const maxWeeks = 25;
|
|
128
|
+
|
|
129
|
+
// Determine how many bars fit
|
|
130
|
+
const maxBars = Math.min(
|
|
131
|
+
data.length,
|
|
132
|
+
maxWeeks,
|
|
133
|
+
Math.floor((availableWidth + gap) / (minBarWidth + gap)),
|
|
134
|
+
);
|
|
114
135
|
const recentData = data.slice(-maxBars);
|
|
115
136
|
|
|
116
137
|
if (recentData.length === 0) {
|
|
117
138
|
return (
|
|
118
139
|
<box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
|
|
119
|
-
<text fg={colors.textSecondary}>
|
|
140
|
+
<text fg={colors.textSecondary}>
|
|
141
|
+
No time entries in the last 6 months
|
|
142
|
+
</text>
|
|
120
143
|
</box>
|
|
121
144
|
);
|
|
122
145
|
}
|
|
123
146
|
|
|
124
|
-
//
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
// Dynamic bar width to fill available space, capped
|
|
148
|
+
const barWidth = Math.min(
|
|
149
|
+
maxBarWidth,
|
|
150
|
+
Math.max(
|
|
151
|
+
minBarWidth,
|
|
152
|
+
Math.floor(
|
|
153
|
+
(availableWidth - (recentData.length - 1) * gap) / recentData.length,
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
133
157
|
|
|
134
|
-
// Collect unique projects for legend (sorted by total time
|
|
158
|
+
// Collect unique projects for legend (sorted by total time)
|
|
135
159
|
const projectTotals = new Map<
|
|
136
160
|
string,
|
|
137
161
|
{ name: string; color: string; totalMs: number }
|
|
@@ -150,18 +174,16 @@ function WeeklyTimeChart({
|
|
|
150
174
|
}
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
|
-
// Sort projects by total time (descending) for consistent stacking order
|
|
154
177
|
const sortedProjects = Array.from(projectTotals.entries()).sort(
|
|
155
178
|
(a, b) => b[1].totalMs - a[1].totalMs,
|
|
156
179
|
);
|
|
157
180
|
|
|
158
|
-
// Assign dynamic colors to each project based on their sorted position
|
|
159
181
|
const projectColorMap = new Map<string, string>();
|
|
160
182
|
sortedProjects.forEach(([projectId], index) => {
|
|
161
183
|
projectColorMap.set(projectId, chartColors[index % chartColors.length]!);
|
|
162
184
|
});
|
|
163
185
|
|
|
164
|
-
// Pre-sort each week's projects by
|
|
186
|
+
// Pre-sort each week's projects by global order for consistent stacking
|
|
165
187
|
const weekProjectsSorted = recentData.map((week) =>
|
|
166
188
|
[...week.projects].sort((a, b) => {
|
|
167
189
|
const aIdx = sortedProjects.findIndex(([id]) => id === a.projectId);
|
|
@@ -170,109 +192,179 @@ function WeeklyTimeChart({
|
|
|
170
192
|
}),
|
|
171
193
|
);
|
|
172
194
|
|
|
173
|
-
|
|
174
|
-
const barHeights = recentData.map((week) =>
|
|
175
|
-
Math.round((week.totalMs / maxMs) * barHeight),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
// Build bars row by row (from top to bottom, +1 row for labels on tallest bars)
|
|
179
|
-
const rows: ReactNode[] = [];
|
|
180
|
-
for (let row = barHeight; row >= 0; row--) {
|
|
181
|
-
const rowParts: ReactNode[] = [];
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i < recentData.length; i++) {
|
|
184
|
-
const week = recentData[i]!;
|
|
185
|
-
const barTotalRows = barHeights[i]!;
|
|
195
|
+
const maxMs = Math.max(...recentData.map((d) => d.totalMs), 1);
|
|
186
196
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
// Build each bar column: array of {char, color} from row 0 (bottom) to top
|
|
198
|
+
const barColumns = recentData.map((week, weekIdx) => {
|
|
199
|
+
const totalFourths = Math.round(
|
|
200
|
+
(week.totalMs / maxMs) * chartHeight * 4,
|
|
201
|
+
);
|
|
202
|
+
const weekProjects = weekProjectsSorted[weekIdx]!;
|
|
203
|
+
const cells: Array<{ char: string; color: string }> = [];
|
|
192
204
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
for (let row = 0; row < chartHeight; row++) {
|
|
206
|
+
const rowBottom = row * 4;
|
|
207
|
+
const fill = Math.min(Math.max(totalFourths - rowBottom, 0), 4);
|
|
196
208
|
|
|
209
|
+
if (fill === 0) {
|
|
210
|
+
cells.push({ char: " ", color: colors.textMuted });
|
|
211
|
+
} else {
|
|
212
|
+
// Find project color at midpoint of filled portion of this row
|
|
213
|
+
const midFourths = rowBottom + fill / 2;
|
|
214
|
+
const midFraction =
|
|
215
|
+
totalFourths > 0 ? midFourths / totalFourths : 0;
|
|
216
|
+
let cumFraction = 0;
|
|
217
|
+
let color = chartColors[0]!;
|
|
197
218
|
for (const proj of weekProjects) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
projectColor =
|
|
219
|
+
cumFraction += proj.ms / week.totalMs;
|
|
220
|
+
if (midFraction <= cumFraction) {
|
|
221
|
+
color =
|
|
202
222
|
projectColorMap.get(proj.projectId) || chartColors[0]!;
|
|
203
223
|
break;
|
|
204
224
|
}
|
|
205
225
|
}
|
|
226
|
+
cells.push({ char: brailleBlocks[fill]!, color });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return cells;
|
|
230
|
+
});
|
|
206
231
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
232
|
+
// Render a label row where labels can extend beyond bar width.
|
|
233
|
+
// Labels are centered on their bar position and skipped if they'd overlap.
|
|
234
|
+
function buildLabelRow(
|
|
235
|
+
labels: string[],
|
|
236
|
+
color: string,
|
|
237
|
+
key: string,
|
|
238
|
+
): ReactNode {
|
|
239
|
+
const totalWidth =
|
|
240
|
+
labels.length * barWidth + (labels.length - 1) * gap;
|
|
241
|
+
const chars = new Array(totalWidth).fill(" ");
|
|
242
|
+
|
|
243
|
+
let lastEnd = -2;
|
|
244
|
+
for (let i = 0; i < labels.length; i++) {
|
|
245
|
+
const label = labels[i]!;
|
|
246
|
+
if (!label) continue;
|
|
247
|
+
|
|
248
|
+
const barStart = i * (barWidth + gap);
|
|
249
|
+
const barCenter = barStart + Math.floor(barWidth / 2);
|
|
250
|
+
const labelStart = Math.max(
|
|
251
|
+
0,
|
|
252
|
+
barCenter - Math.floor(label.length / 2),
|
|
253
|
+
);
|
|
254
|
+
const labelEnd = labelStart + label.length;
|
|
255
|
+
|
|
256
|
+
if (labelStart > lastEnd && labelEnd <= totalWidth) {
|
|
257
|
+
for (let j = 0; j < label.length; j++) {
|
|
258
|
+
chars[labelStart + j] = label[j];
|
|
259
|
+
}
|
|
260
|
+
lastEnd = labelEnd;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<text key={key} fg={color}>
|
|
266
|
+
{chars.join("")}
|
|
267
|
+
</text>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Short top labels: rounded hours with no unit
|
|
272
|
+
const topLabels = recentData.map((week) => {
|
|
273
|
+
if (week.totalMs === 0) return "";
|
|
274
|
+
const hours = week.totalMs / 3600000;
|
|
275
|
+
const rounded = Math.round(hours);
|
|
276
|
+
return String(rounded || "<1");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Row where each label sits (0-indexed from bottom, just above bar top)
|
|
280
|
+
const barLabelRows = recentData.map((week) => {
|
|
281
|
+
const totalFourths = Math.round(
|
|
282
|
+
(week.totalMs / maxMs) * chartHeight * 4,
|
|
283
|
+
);
|
|
284
|
+
return Math.min(Math.ceil(totalFourths / 4), chartHeight - 1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const rows: ReactNode[] = [];
|
|
288
|
+
|
|
289
|
+
// Bar rows (top to bottom), with labels embedded on top of each bar
|
|
290
|
+
for (let row = chartHeight - 1; row >= 0; row--) {
|
|
291
|
+
const rowParts: ReactNode[] = [];
|
|
292
|
+
for (let i = 0; i < barColumns.length; i++) {
|
|
293
|
+
const isLast = i === barColumns.length - 1;
|
|
294
|
+
if (row === barLabelRows[i] && topLabels[i]) {
|
|
295
|
+
const label = topLabels[i]!;
|
|
296
|
+
// Use barWidth + gap for centering (absorb trailing gap)
|
|
297
|
+
const slotWidth = isLast ? barWidth : barWidth + gap;
|
|
298
|
+
const padLeft = Math.floor((slotWidth - label.length) / 2);
|
|
217
299
|
const centered =
|
|
218
300
|
" ".repeat(padLeft) +
|
|
219
|
-
|
|
220
|
-
" ".repeat(
|
|
301
|
+
label +
|
|
302
|
+
" ".repeat(Math.max(0, slotWidth - padLeft - label.length));
|
|
221
303
|
rowParts.push(
|
|
222
304
|
<span key={i} fg={colors.textSecondary}>
|
|
223
305
|
{centered}
|
|
224
306
|
</span>,
|
|
225
307
|
);
|
|
226
308
|
} else {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
const cell = barColumns[i]![row]!;
|
|
310
|
+
rowParts.push(
|
|
311
|
+
<span key={i} fg={cell.color}>
|
|
312
|
+
{cell.char.repeat(barWidth)}
|
|
313
|
+
</span>,
|
|
314
|
+
);
|
|
315
|
+
if (!isLast) {
|
|
316
|
+
rowParts.push(
|
|
317
|
+
<span key={`sp-${i}`}>{" ".repeat(gap)}</span>,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
232
320
|
}
|
|
233
321
|
}
|
|
234
|
-
|
|
235
|
-
rows.push(<text key={row}>{rowParts}</text>);
|
|
322
|
+
rows.push(<text key={`row-${row}`}>{rowParts}</text>);
|
|
236
323
|
}
|
|
237
324
|
|
|
238
|
-
//
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
// Build date labels row
|
|
242
|
-
const dateLabelParts: ReactNode[] = [];
|
|
325
|
+
// Baseline
|
|
326
|
+
const baselineParts: ReactNode[] = [];
|
|
243
327
|
for (let i = 0; i < recentData.length; i++) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const centered =
|
|
248
|
-
" ".repeat(padLeft) +
|
|
249
|
-
trimmed +
|
|
250
|
-
" ".repeat(barWidth - padLeft - trimmed.length);
|
|
251
|
-
dateLabelParts.push(
|
|
252
|
-
<span key={i} fg={colors.textSecondary}>
|
|
253
|
-
{centered}
|
|
328
|
+
baselineParts.push(
|
|
329
|
+
<span key={i} fg={colors.borderSubtle}>
|
|
330
|
+
{"─".repeat(barWidth)}
|
|
254
331
|
</span>,
|
|
255
332
|
);
|
|
256
333
|
if (i < recentData.length - 1) {
|
|
257
|
-
|
|
334
|
+
baselineParts.push(
|
|
335
|
+
<span key={`sp-${i}`} fg={colors.borderSubtle}>
|
|
336
|
+
{" ".repeat(gap)}
|
|
337
|
+
</span>,
|
|
338
|
+
);
|
|
258
339
|
}
|
|
259
340
|
}
|
|
341
|
+
rows.push(<text key="baseline">{baselineParts}</text>);
|
|
342
|
+
|
|
343
|
+
// Date labels
|
|
344
|
+
const dateLabels = recentData.map((week) => week.weekLabel);
|
|
345
|
+
rows.push(buildLabelRow(dateLabels, colors.textSecondary, "dates"));
|
|
346
|
+
|
|
347
|
+
const totalMs = recentData.reduce((sum, w) => sum + w.totalMs, 0);
|
|
260
348
|
|
|
261
349
|
return (
|
|
262
|
-
<box style={{ flexDirection: "column",
|
|
263
|
-
{/* Bars (with hour labels at top) */}
|
|
350
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
264
351
|
{rows}
|
|
265
|
-
{/*
|
|
266
|
-
<text>{dateLabelParts}</text>
|
|
267
|
-
|
|
268
|
-
{/* Legend - show top projects by time */}
|
|
352
|
+
{/* Legend */}
|
|
269
353
|
<box
|
|
270
|
-
style={{
|
|
354
|
+
style={{
|
|
355
|
+
flexDirection: "row",
|
|
356
|
+
gap: 2,
|
|
357
|
+
marginTop: 1,
|
|
358
|
+
flexWrap: "wrap",
|
|
359
|
+
}}
|
|
271
360
|
>
|
|
272
361
|
{sortedProjects.slice(0, 4).map(([id, proj]) => (
|
|
273
362
|
<text key={id}>
|
|
274
363
|
<span fg={projectColorMap.get(id)}>●</span>
|
|
275
|
-
<span fg={colors.textSecondary}>
|
|
364
|
+
<span fg={colors.textSecondary}>
|
|
365
|
+
{" "}
|
|
366
|
+
{proj.name.slice(0, 12)}
|
|
367
|
+
</span>
|
|
276
368
|
</text>
|
|
277
369
|
))}
|
|
278
370
|
<text>
|