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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pacatui",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "A simple tui app for task, timer and invoicing for projects.",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -109,29 +109,53 @@ function WeeklyTimeChart({
109
109
  }) {
110
110
  const colors = theme.colors;
111
111
  const chartColors = theme.projectColors;
112
- const chartWidth = Math.max(width + 4, 40);
113
- const maxBars = Math.min(data.length, 25, Math.floor(chartWidth / 3)); // Each bar needs ~3 chars min, max 25 weeks
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}>No time entries in the last 6 months</text>
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
- // Find max hours for scaling
125
- const maxMs = Math.max(...recentData.map((d) => d.totalMs), 1);
126
- const barHeight = 5; // Height of bars in rows
127
-
128
- // Calculate bar width to fill available space
129
- // Total width = (barWidth * n) + (n - 1) spaces = chartWidth
130
- // barWidth = (chartWidth - n + 1) / n
131
-
132
- const barWidth = 4;
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 across all weeks)
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 the global order for consistent stacking
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
- // Pre-calculate bar heights for label positioning
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
- if (row < barTotalRows) {
188
- // This row should be filled - determine which project's color
189
- // Use fraction-based calculation to avoid rounding errors
190
- const rowFraction = (row + 0.5) / barTotalRows;
191
- const weekProjects = weekProjectsSorted[i]!;
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
- // Find which project this row belongs to based on cumulative fraction
194
- let cumulativeFraction = 0;
195
- let projectColor = chartColors[0]!;
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
- const projFraction = proj.ms / week.totalMs;
199
- cumulativeFraction += projFraction;
200
- if (rowFraction <= cumulativeFraction) {
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
- rowParts.push(
208
- <span key={i} fg={projectColor}>
209
- {"█".repeat(barWidth)}
210
- </span>,
211
- );
212
- } else if (row === barTotalRows && week.totalMs > 0) {
213
- // Show hour label just above the bar (centered)
214
- const label = formatHours(week.totalMs);
215
- const trimmed = label.slice(0, barWidth);
216
- const padLeft = Math.floor((barWidth - trimmed.length) / 2);
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
- trimmed +
220
- " ".repeat(barWidth - padLeft - trimmed.length);
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
- rowParts.push(<span key={i}>{" ".repeat(barWidth)}</span>);
228
- }
229
- // Add space between bars
230
- if (i < recentData.length - 1) {
231
- rowParts.push(<span key={`sp-${i}`}> </span>);
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
- // Calculate total hours for display
239
- const totalMs = recentData.reduce((sum, w) => sum + w.totalMs, 0);
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
- const week = recentData[i]!;
245
- const trimmed = week.weekLabel.slice(0, barWidth);
246
- const padLeft = Math.floor((barWidth - trimmed.length) / 2);
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
- dateLabelParts.push(<span key={`sp-${i}`}> </span>);
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", gap: 1, width: "100%" }}>
263
- {/* Bars (with hour labels at top) */}
350
+ <box style={{ flexDirection: "column", width: "100%" }}>
264
351
  {rows}
265
- {/* Date labels */}
266
- <text>{dateLabelParts}</text>
267
-
268
- {/* Legend - show top projects by time */}
352
+ {/* Legend */}
269
353
  <box
270
- style={{ flexDirection: "row", gap: 2, marginTop: 1, flexWrap: "wrap" }}
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}> {proj.name.slice(0, 12)}</span>
364
+ <span fg={colors.textSecondary}>
365
+ {" "}
366
+ {proj.name.slice(0, 12)}
367
+ </span>
276
368
  </text>
277
369
  ))}
278
370
  <text>