@mongoosejs/studio 0.3.6 → 0.3.8

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.
Files changed (28) hide show
  1. package/backend/actions/Dashboard/getDashboard.js +2 -1
  2. package/backend/actions/Task/getTasksOverTime.js +66 -0
  3. package/backend/actions/Task/index.js +1 -0
  4. package/backend/integrations/callLLM.js +2 -1
  5. package/backend/integrations/streamLLM.js +2 -1
  6. package/backend/netlify.js +2 -1
  7. package/backend/next.js +2 -1
  8. package/constants.js +7 -0
  9. package/express.js +2 -1
  10. package/frontend/index.js +2 -1
  11. package/frontend/public/app.js +541 -88
  12. package/frontend/public/tw.css +14 -12
  13. package/frontend/src/api.js +6 -0
  14. package/frontend/src/dashboard-result/dashboard-primitive/dashboard-primitive.html +2 -2
  15. package/frontend/src/dashboard-result/dashboard-primitive/dashboard-primitive.js +13 -1
  16. package/frontend/src/dashboard-result/dashboard-result.html +1 -1
  17. package/frontend/src/dashboard-result/dashboard-table/dashboard-table.html +21 -1
  18. package/frontend/src/dashboard-result/dashboard-table/dashboard-table.js +52 -0
  19. package/frontend/src/detail-date/detail-date.html +1 -0
  20. package/frontend/src/detail-date/detail-date.js +123 -0
  21. package/frontend/src/document-details/date-view-mode-picker/date-view-mode-picker.html +26 -0
  22. package/frontend/src/document-details/date-view-mode-picker/date-view-mode-picker.js +41 -0
  23. package/frontend/src/document-details/document-property/document-property.html +13 -5
  24. package/frontend/src/document-details/document-property/document-property.js +14 -1
  25. package/frontend/src/tasks/tasks.html +34 -20
  26. package/frontend/src/tasks/tasks.js +158 -5
  27. package/local.js +2 -1
  28. package/package.json +1 -1
@@ -1220,6 +1220,10 @@ video {
1220
1220
  width: 1rem;
1221
1221
  }
1222
1222
 
1223
+ .w-40 {
1224
+ width: 10rem;
1225
+ }
1226
+
1223
1227
  .w-44 {
1224
1228
  width: 11rem;
1225
1229
  }
@@ -1236,6 +1240,10 @@ video {
1236
1240
  width: 13rem;
1237
1241
  }
1238
1242
 
1243
+ .w-56 {
1244
+ width: 14rem;
1245
+ }
1246
+
1239
1247
  .w-6 {
1240
1248
  width: 1.5rem;
1241
1249
  }
@@ -3060,14 +3068,6 @@ video {
3060
3068
  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
3061
3069
  }
3062
3070
 
3063
- .hover\:rounded-lg:hover {
3064
- border-radius: 0.5rem;
3065
- }
3066
-
3067
- .hover\:border:hover {
3068
- border-width: 1px;
3069
- }
3070
-
3071
3071
  .hover\:border-edge-strong:hover {
3072
3072
  border-color: var(--color-edge-strong);
3073
3073
  }
@@ -3617,10 +3617,6 @@ video {
3617
3617
  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
3618
3618
  }
3619
3619
 
3620
- .group:hover .group-hover\:text-content-secondary {
3621
- color: var(--color-content-secondary);
3622
- }
3623
-
3624
3620
  .group:hover .group-hover\:text-primary {
3625
3621
  color: var(--color-primary);
3626
3622
  }
@@ -3796,6 +3792,12 @@ video {
3796
3792
  }
3797
3793
  }
3798
3794
 
3795
+ @media (min-width: 1280px) {
3796
+ .xl\:grid-cols-3 {
3797
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3798
+ }
3799
+ }
3800
+
3799
3801
  :is(:where(.dark) .dark\:bg-shark-800) {
3800
3802
  --tw-bg-opacity: 1;
3801
3803
  background-color: rgb(33 37 41 / var(--tw-bg-opacity));
@@ -191,6 +191,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
191
191
  getTaskOverview: function getTaskOverview(params) {
192
192
  return client.post('', { action: 'Task.getTaskOverview', ...params }).then(res => res.data);
193
193
  },
194
+ getTasksOverTime: function getTasksOverTime(params) {
195
+ return client.post('', { action: 'Task.getTasksOverTime', ...params }).then(res => res.data);
196
+ },
194
197
  rescheduleTask: function rescheduleTask(params) {
195
198
  return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data);
196
199
  },
@@ -531,6 +534,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
531
534
  getTaskOverview: function getTaskOverview(params) {
532
535
  return client.post('/Task/getTaskOverview', params).then(res => res.data);
533
536
  },
537
+ getTasksOverTime: function getTasksOverTime(params) {
538
+ return client.post('/Task/getTasksOverTime', params).then(res => res.data);
539
+ },
534
540
  rescheduleTask: function rescheduleTask(params) {
535
541
  return client.post('/Task/rescheduleTask', params).then(res => res.data);
536
542
  },
@@ -2,7 +2,7 @@
2
2
  <div v-if="header" class="border-b border-gray-100 px-2 pb-2 text-xl font-bold">
3
3
  {{header}}
4
4
  </div>
5
- <div class="text-xl p-2">
5
+ <div class="text-xl p-2" :class="displayClass">
6
6
  {{displayValue}}
7
7
  </div>
8
- </div>
8
+ </div>
@@ -9,16 +9,28 @@ module.exports = app => app.component('dashboard-primitive', {
9
9
  props: ['value'],
10
10
  computed: {
11
11
  header() {
12
- if (this.value != null && this.value.$primitive.header) {
12
+ if (this.value != null && this.value.$primitive?.header) {
13
13
  return this.value.$primitive.header;
14
14
  }
15
15
  return null;
16
16
  },
17
17
  displayValue() {
18
18
  if (this.value != null && this.value.$primitive) {
19
+ if (this.value.$primitive.value === null) {
20
+ return 'null';
21
+ }
19
22
  return this.value.$primitive.value;
20
23
  }
24
+ if (this.value === null) {
25
+ return 'null';
26
+ }
21
27
  return this.value;
28
+ },
29
+ displayClass() {
30
+ if (this.value == null) {
31
+ return 'text-content-tertiary';
32
+ }
33
+ return null;
22
34
  }
23
35
  }
24
36
  });
@@ -1,6 +1,6 @@
1
1
  <div>
2
2
  <div v-if="Array.isArray(result)">
3
- <div v-for="el in result" :key="el._id || el.finishedEvaluatingAt">
3
+ <div v-for="(el, index) in result">
4
4
  <component
5
5
  class="bg-surface shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl"
6
6
  :is="getComponentForValue(el)"
@@ -7,7 +7,27 @@
7
7
  :key="'column-' + index"
8
8
  class="bg-slate-50 p-3 text-left text-sm font-semibold text-content border-b border-edge"
9
9
  >
10
- {{ column }}
10
+ <div v-if="index === columns.length - 1" class="relative flex items-center gap-2 w-full" ref="dropdown">
11
+ <span class="min-w-0 flex-1">{{ column }}</span>
12
+ <button
13
+ @click.stop="toggleDropdown"
14
+ class="ml-auto rounded p-1 text-content-secondary hover:bg-muted hover:text-content"
15
+ aria-label="Table actions">
16
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
17
+ <path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
18
+ </svg>
19
+ </button>
20
+ <div
21
+ v-if="showDropdown"
22
+ class="absolute right-0 top-full z-10 mt-2 w-56 origin-top-right rounded-md bg-surface py-1 shadow-lg ring-1 ring-black/5">
23
+ <button
24
+ class="block w-full text-left px-4 py-2 text-xs text-content-secondary hover:bg-muted"
25
+ @click="downloadCsv(); showDropdown = false">
26
+ Download as CSV
27
+ </button>
28
+ </div>
29
+ </div>
30
+ <template v-else>{{ column }}</template>
11
31
  </th>
12
32
  </tr>
13
33
  </thead>
@@ -1,3 +1,4 @@
1
+ /* global Blob, URL, document */
1
2
  'use strict';
2
3
 
3
4
  const template = require('./dashboard-table.html');
@@ -5,6 +6,11 @@ const template = require('./dashboard-table.html');
5
6
  module.exports = app => app.component('dashboard-table', {
6
7
  template,
7
8
  props: ['value'],
9
+ data() {
10
+ return {
11
+ showDropdown: false
12
+ };
13
+ },
8
14
  computed: {
9
15
  columns() {
10
16
  return Array.isArray(this.value?.$table?.columns) ? this.value.$table.columns : [];
@@ -20,6 +26,46 @@ module.exports = app => app.component('dashboard-table', {
20
26
  }
21
27
  },
22
28
  methods: {
29
+ toggleDropdown() {
30
+ this.showDropdown = !this.showDropdown;
31
+ },
32
+ handleBodyClick(event) {
33
+ const dropdownRefs = this.$refs.dropdown;
34
+ const dropdowns = Array.isArray(dropdownRefs) ? dropdownRefs : [dropdownRefs];
35
+ const hasClickInsideDropdown = dropdowns
36
+ .filter(dropdown => dropdown && typeof dropdown.contains === 'function')
37
+ .some(dropdown => dropdown.contains(event.target));
38
+
39
+ if (!hasClickInsideDropdown) {
40
+ this.showDropdown = false;
41
+ }
42
+ },
43
+ neutralizeCsvCell(cell) {
44
+ const value = this.displayValue(cell);
45
+ return /^\s*[=+\-@]/.test(value) ? `'${value}` : value;
46
+ },
47
+ escapeCsvCell(cell) {
48
+ const escapedCell = this.neutralizeCsvCell(cell).replaceAll('"', '""');
49
+ return `"${escapedCell}"`;
50
+ },
51
+ downloadCsv() {
52
+ const header = this.columns.map(this.escapeCsvCell).join(',');
53
+ const rows = this.rows
54
+ .map(row => row.map(this.escapeCsvCell).join(','))
55
+ .join('\n');
56
+
57
+ const csv = [header, rows].filter(v => v.length > 0).join('\n');
58
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
59
+ const url = URL.createObjectURL(blob);
60
+ const anchor = document.createElement('a');
61
+ anchor.href = url;
62
+ anchor.download = 'table.csv';
63
+ document.body.appendChild(anchor);
64
+ anchor.click();
65
+ document.body.removeChild(anchor);
66
+ URL.revokeObjectURL(url);
67
+ this.$toast.success('CSV downloaded!');
68
+ },
23
69
  displayValue(cell) {
24
70
  if (cell == null) {
25
71
  return '';
@@ -33,5 +79,11 @@ module.exports = app => app.component('dashboard-table', {
33
79
  }
34
80
  return String(cell);
35
81
  }
82
+ },
83
+ mounted() {
84
+ document.body.addEventListener('click', this.handleBodyClick);
85
+ },
86
+ unmounted() {
87
+ document.body.removeEventListener('click', this.handleBodyClick);
36
88
  }
37
89
  });
@@ -0,0 +1 @@
1
+ <pre class="w-full whitespace-pre-wrap break-words font-mono text-sm text-content-secondary m-0">{{displayValue}}</pre>
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const template = require('./detail-date.html');
4
+
5
+ module.exports = app => app.component('detail-date', {
6
+ template,
7
+ props: ['value', 'viewMode'],
8
+ emits: ['updated'],
9
+ watch: {
10
+ displayValue: {
11
+ immediate: true,
12
+ handler(val) {
13
+ this.$emit('updated', val);
14
+ }
15
+ }
16
+ },
17
+ computed: {
18
+ format() {
19
+ if (this.viewMode != null && typeof this.viewMode === 'object') {
20
+ return this.viewMode.format;
21
+ }
22
+ return this.viewMode;
23
+ },
24
+ timezone() {
25
+ if (this.viewMode != null && typeof this.viewMode === 'object') {
26
+ return this.viewMode.timezone || '';
27
+ }
28
+ return '';
29
+ },
30
+ parsedDate() {
31
+ if (this.value == null) {
32
+ return null;
33
+ }
34
+ const date = new Date(this.value);
35
+ return Number.isNaN(date.getTime()) ? null : date;
36
+ },
37
+ displayValue() {
38
+ if (this.value == null) {
39
+ return String(this.value);
40
+ }
41
+ if (!this.parsedDate) {
42
+ return 'Invalid Date';
43
+ }
44
+ return this.formatDateForDisplay(this.parsedDate);
45
+ }
46
+ },
47
+ methods: {
48
+ formatDateForDisplay(date) {
49
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
50
+ return 'Invalid Date';
51
+ }
52
+
53
+ if (this.format === 'utc_iso') {
54
+ return date.toISOString();
55
+ }
56
+
57
+ if (this.format === 'local_browser') {
58
+ return date.toLocaleString();
59
+ }
60
+
61
+ if (this.format === 'unix_ms') {
62
+ return String(date.getTime());
63
+ }
64
+
65
+ if (this.format === 'unix_seconds') {
66
+ return String(Math.floor(date.getTime() / 1000));
67
+ }
68
+
69
+ if (this.format === 'duration_relative') {
70
+ return this.formatRelativeDuration(date);
71
+ }
72
+
73
+ if (this.format === 'custom_tz') {
74
+ return this.formatCustomTimezone(date);
75
+ }
76
+
77
+ return date.toISOString();
78
+ },
79
+ formatRelativeDuration(date) {
80
+ const diffMs = date.getTime() - Date.now();
81
+ const absMs = Math.abs(diffMs);
82
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
83
+ const units = [
84
+ { unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
85
+ { unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
86
+ { unit: 'day', ms: 24 * 60 * 60 * 1000 },
87
+ { unit: 'hour', ms: 60 * 60 * 1000 },
88
+ { unit: 'minute', ms: 60 * 1000 },
89
+ { unit: 'second', ms: 1000 }
90
+ ];
91
+
92
+ for (const { unit, ms } of units) {
93
+ if (absMs >= ms || unit === 'second') {
94
+ const value = Math.round(diffMs / ms);
95
+ return rtf.format(value, unit);
96
+ }
97
+ }
98
+
99
+ return 'now';
100
+ },
101
+ formatCustomTimezone(date) {
102
+ const tz = (this.timezone || '').trim();
103
+ if (!tz) {
104
+ return `${date.toISOString()} (enter an IANA timezone)`;
105
+ }
106
+
107
+ try {
108
+ return new Intl.DateTimeFormat(undefined, {
109
+ timeZone: tz,
110
+ year: 'numeric',
111
+ month: '2-digit',
112
+ day: '2-digit',
113
+ hour: '2-digit',
114
+ minute: '2-digit',
115
+ second: '2-digit',
116
+ timeZoneName: 'short'
117
+ }).format(date);
118
+ } catch (err) {
119
+ return `Invalid timezone: ${tz}`;
120
+ }
121
+ }
122
+ }
123
+ });
@@ -0,0 +1,26 @@
1
+ <div class="flex items-center gap-2" @click.stop>
2
+ <select
3
+ :value="format"
4
+ @input="onFormatChange($event.target.value)"
5
+ class="text-xs border border-edge rounded-md bg-surface px-2 py-1"
6
+ >
7
+ <option value="utc_iso">UTC (ISO)</option>
8
+ <option value="local_browser">Local (Browser)</option>
9
+ <option value="unix_ms">Unix (ms)</option>
10
+ <option value="unix_seconds">Unix (seconds)</option>
11
+ <option value="duration_relative">Duration relative to now</option>
12
+ <option value="custom_tz">Custom TZ...</option>
13
+ </select>
14
+ <input
15
+ v-if="format === 'custom_tz'"
16
+ :value="timezone"
17
+ @input="onTimezoneChange($event.target.value.trim())"
18
+ :list="timezoneDatalistId"
19
+ type="text"
20
+ placeholder="America/New_York"
21
+ class="text-xs border border-edge rounded-md bg-surface px-2 py-1 w-40"
22
+ >
23
+ <datalist v-if="format === 'custom_tz'" :id="timezoneDatalistId">
24
+ <option v-for="tz in timezones" :key="tz" :value="tz"></option>
25
+ </datalist>
26
+ </div>
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const template = require('./date-view-mode-picker.html');
4
+
5
+ module.exports = app => app.component('date-view-mode-picker', {
6
+ template,
7
+ props: ['viewMode', 'path'],
8
+ emits: ['update:viewMode'],
9
+ computed: {
10
+ format() {
11
+ if (this.viewMode != null && typeof this.viewMode === 'object') {
12
+ return this.viewMode.format;
13
+ }
14
+ return this.viewMode;
15
+ },
16
+ timezone() {
17
+ if (this.viewMode != null && typeof this.viewMode === 'object') {
18
+ return this.viewMode.timezone || '';
19
+ }
20
+ return '';
21
+ },
22
+ timezoneDatalistId() {
23
+ return `timezone-options-${String(this.path?.path || '').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
24
+ },
25
+ timezones() {
26
+ return Intl.supportedValuesOf('timeZone');
27
+ }
28
+ },
29
+ methods: {
30
+ onFormatChange(newFormat) {
31
+ if (newFormat === 'custom_tz') {
32
+ this.$emit('update:viewMode', { format: 'custom_tz', timezone: this.timezone });
33
+ } else {
34
+ this.$emit('update:viewMode', newFormat);
35
+ }
36
+ },
37
+ onTimezoneChange(newTimezone) {
38
+ this.$emit('update:viewMode', { format: 'custom_tz', timezone: newTimezone });
39
+ }
40
+ }
41
+ });
@@ -70,6 +70,12 @@
70
70
  </div>
71
71
  </div>
72
72
  <div class="flex items-center gap-2">
73
+ <date-view-mode-picker
74
+ v-if="isDatePath"
75
+ :viewMode="dateViewMode"
76
+ :path="path"
77
+ @update:viewMode="dateViewMode = $event"
78
+ ></date-view-mode-picker>
73
79
  <button
74
80
  type="button"
75
81
  class="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-edge-strong bg-surface"
@@ -125,7 +131,7 @@
125
131
  v-if="isGeoJsonGeometry"
126
132
  :is="getComponentForPath(path)"
127
133
  :value="getEditValueForPath(path)"
128
- :view-mode="detailViewMode"
134
+ :viewMode="detailViewMode"
129
135
  :on-change="handleInputChange"
130
136
  >
131
137
  </component>
@@ -195,8 +201,9 @@
195
201
  <div v-else-if="needsTruncation && isValueExpanded" class="relative">
196
202
  <component
197
203
  :is="getComponentForPath(path)"
198
- :value="getValueForPath(path.path)"
199
- :view-mode="detailViewMode"></component>
204
+ :value="rawValue"
205
+ :viewMode="isDatePath ? dateViewMode : detailViewMode"
206
+ @updated="renderedValue = $event"></component>
200
207
  <button
201
208
  @click="toggleValueExpansion"
202
209
  class="mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5"
@@ -211,8 +218,9 @@
211
218
  <div v-else>
212
219
  <component
213
220
  :is="getComponentForPath(path)"
214
- :value="getValueForPath(path.path)"
215
- :view-mode="detailViewMode"></component>
221
+ :value="rawValue"
222
+ :viewMode="isDatePath ? dateViewMode : detailViewMode"
223
+ @updated="renderedValue = $event"></component>
216
224
  </div>
217
225
  </div>
218
226
  </div>
@@ -10,11 +10,15 @@ const appendCSS = require('../../appendCSS');
10
10
 
11
11
  appendCSS(require('./document-property.css'));
12
12
 
13
+ const UNSET = Symbol('unset');
14
+
13
15
  module.exports = app => app.component('document-property', {
14
16
  template,
15
17
  data: function() {
16
18
  return {
17
19
  dateType: 'picker', // picker, iso
20
+ dateViewMode: 'utc_iso',
21
+ renderedValue: UNSET,
18
22
  isCollapsed: false, // Start uncollapsed by default
19
23
  isValueExpanded: false, // Track if the value is expanded
20
24
  detailViewMode: 'text',
@@ -31,8 +35,14 @@ module.exports = app => app.component('document-property', {
31
35
  },
32
36
  props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight'],
33
37
  computed: {
38
+ isDatePath() {
39
+ return this.path?.instance === 'Date';
40
+ },
41
+ rawValue() {
42
+ return this.getValueForPath(this.path.path);
43
+ },
34
44
  valueAsString() {
35
- const value = this.getValueForPath(this.path.path);
45
+ const value = this.renderedValue !== UNSET ? this.renderedValue : this.rawValue;
36
46
  if (value == null) {
37
47
  return String(value);
38
48
  }
@@ -164,6 +174,9 @@ module.exports = app => app.component('document-property', {
164
174
  if (schemaPath.instance === 'Array') {
165
175
  return 'detail-array';
166
176
  }
177
+ if (schemaPath.instance === 'Date') {
178
+ return 'detail-date';
179
+ }
167
180
  return 'detail-default';
168
181
  },
169
182
  getEditComponentForPath(path) {
@@ -79,63 +79,77 @@
79
79
  <div class="text-2xl font-bold">{{cancelledCount}}</div>
80
80
  </button>
81
81
  </div>
82
+
83
+ <!-- Tasks Over Time Chart -->
84
+ <!--
85
+ Canvas is gated by showOverTimeChart (v-if) so Chart.js is destroyed
86
+ and the DOM node is removed before each refresh. In-place Chart.js
87
+ updates during Vue re-renders from filter changes could freeze the UI
88
+ (dropdowns stuck, chart not updating). See tasks.js getTasks().
89
+ -->
90
+ <div class="mt-6">
91
+ <h2 class="text-lg font-semibold text-content-secondary mb-3">Tasks Over Time</h2>
92
+ <div class="bg-page border border-edge rounded-lg p-4" style="height: 260px;">
93
+ <canvas v-if="showOverTimeChart && overTimeBuckets.length > 0" ref="overTimeChart" style="width:100%;height:100%;"></canvas>
94
+ <div v-else class="flex items-center justify-center h-full text-content-tertiary text-sm">
95
+ No task activity in the selected window
96
+ </div>
97
+ </div>
98
+ </div>
82
99
 
83
100
  <!-- Grouped Task List -->
84
101
  <div class="mt-6">
85
102
  <h2 class="text-lg font-semibold text-content-secondary mb-4">Tasks by Name</h2>
86
- <ul class="divide-y divide-gray-200">
87
- <li v-for="group in tasksByName" :key="group.name" class="p-4 group hover:border hover:rounded-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-200">
88
- <div class="flex items-center justify-between mb-3 ">
89
- <div class="flex-1 cursor-pointer" @click="openTaskGroupDetails(group)">
90
- <div class="flex items-center gap-2">
91
- <div class="font-medium text-lg group-hover:text-primary transition-colors">{{ group.name }}</div>
92
- <svg class="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
103
+ <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
104
+ <div v-for="group in tasksByName" :key="group.name" class="border border-edge rounded-lg p-4 group hover:shadow-xl transform hover:-translate-y-1 transition-all duration-200">
105
+ <div class="flex items-start justify-between mb-3">
106
+ <div class="flex-1 cursor-pointer min-w-0 mr-2" @click="openTaskGroupDetails(group)">
107
+ <div class="flex items-center gap-1">
108
+ <div class="font-medium text-sm group-hover:text-primary transition-colors truncate">{{ group.name }}</div>
109
+ <svg class="w-3 h-3 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93
110
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
94
111
  </svg>
95
112
  </div>
96
- <div class="text-sm text-content-tertiary group-hover:text-content-secondary transition-colors">Total: {{ group.totalCount }} tasks</div>
97
- <div class="text-xs text-primary opacity-0 group-hover:opacity-100 transition-opacity mt-1">
98
- Click to view details
99
- </div>
113
+ <div class="text-xs text-content-tertiary">Total: {{ group.totalCount }} tasks</div>
100
114
  </div>
101
- <div class="text-sm text-content-tertiary">
102
- Last run: {{ group.lastRun ? new Date(group.lastRun).toLocaleString() : 'Never' }}
115
+ <div class="text-xs text-content-tertiary flex-shrink-0">
116
+ {{ group.lastRun ? new Date(group.lastRun).toLocaleString() : 'Never' }}
103
117
  </div>
104
118
  </div>
105
119
 
106
120
  <!-- Status Counts -->
107
- <div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
121
+ <div class="grid grid-cols-2 gap-1.5">
108
122
  <button
109
123
  @click.stop="openTaskGroupDetailsWithFilter(group, 'pending')"
110
124
  class="bg-yellow-50 border border-yellow-200 rounded-md p-2 text-center shadow-sm hover:shadow-md transform hover:-translate-y-1 transition-all duration-200 cursor-pointer hover:border-yellow-300"
111
125
  >
112
126
  <div class="text-xs text-yellow-600 font-medium">Pending</div>
113
- <div class="text-lg font-bold text-yellow-700">{{ group.statusCounts.pending || 0 }}</div>
127
+ <div class="text-base font-bold text-yellow-700">{{ group.statusCounts.pending || 0 }}</div>
114
128
  </button>
115
129
  <button
116
130
  @click.stop="openTaskGroupDetailsWithFilter(group, 'succeeded')"
117
131
  class="bg-green-50 border border-green-200 rounded-md p-2 text-center shadow-sm hover:shadow-md transform hover:-translate-y-1 transition-all duration-200 cursor-pointer hover:border-green-300"
118
132
  >
119
133
  <div class="text-xs text-green-600 font-medium">Succeeded</div>
120
- <div class="text-lg font-bold text-green-700">{{ group.statusCounts.succeeded || 0 }}</div>
134
+ <div class="text-base font-bold text-green-700">{{ group.statusCounts.succeeded || 0 }}</div>
121
135
  </button>
122
136
  <button
123
137
  @click.stop="openTaskGroupDetailsWithFilter(group, 'failed')"
124
138
  class="bg-red-50 border border-red-200 rounded-md p-2 text-center shadow-sm hover:shadow-md transform hover:-translate-y-1 transition-all duration-200 cursor-pointer hover:border-red-300"
125
139
  >
126
140
  <div class="text-xs text-red-600 font-medium">Failed</div>
127
- <div class="text-lg font-bold text-red-700">{{ group.statusCounts.failed || 0 }}</div>
141
+ <div class="text-base font-bold text-red-700">{{ group.statusCounts.failed || 0 }}</div>
128
142
  </button>
129
143
  <button
130
144
  @click.stop="openTaskGroupDetailsWithFilter(group, 'cancelled')"
131
145
  class="bg-page border border-edge rounded-md p-2 text-center shadow-sm hover:shadow-md transform hover:-translate-y-1 transition-all duration-200 cursor-pointer hover:border-edge-strong"
132
146
  >
133
147
  <div class="text-xs text-gray-600 font-medium">Cancelled</div>
134
- <div class="text-lg font-bold text-content-secondary">{{ group.statusCounts.cancelled || 0 }}</div>
148
+ <div class="text-base font-bold text-content-secondary">{{ group.statusCounts.cancelled || 0 }}</div>
135
149
  </button>
136
150
  </div>
137
- </li>
138
- </ul>
151
+ </div>
152
+ </div>
139
153
  </div>
140
154
  </div>
141
155
  </div>