@conduction/nextcloud-vue 2.83.0 → 2.85.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.
Files changed (246) hide show
  1. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  2. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +131 -3
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +54 -18
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  7. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +1 -1
  8. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  10. package/dist/esm/components/CnBoardView/CnBoardView.vue.js +1 -1
  11. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +232 -4
  12. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  13. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js +95 -51
  14. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  15. package/dist/esm/components/CnBodySections/CnBodySections.vue.js +1 -1
  16. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js +22 -1
  17. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBodySections/CnBodySections.vue3.js.map +1 -1
  19. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  20. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +47 -4
  21. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue.js +1 -1
  24. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +239 -3
  25. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +5 -5
  27. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  29. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +50 -0
  30. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  31. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +22 -12
  32. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue.js +1 -1
  34. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js +1 -1
  35. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +226 -8
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  41. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +3 -1
  42. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  43. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  44. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  45. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +18 -5
  46. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  49. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +38 -1
  50. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +45 -5
  52. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  53. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue.js +1 -1
  54. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js +1 -1
  55. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js.map +1 -1
  56. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +548 -77
  58. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  59. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +191 -82
  60. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  61. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +11 -2
  62. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  63. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js +29 -6
  64. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js.map +1 -1
  65. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue3.js.map +1 -1
  66. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  67. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +126 -2
  68. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  69. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +24 -2
  70. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  71. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  72. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +106 -3
  73. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  74. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  75. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  76. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +67 -5
  77. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  78. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js +181 -124
  79. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  80. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  81. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +135 -2
  82. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  83. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +95 -43
  84. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  85. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  86. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +5 -1
  87. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  88. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  89. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
  90. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
  91. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  92. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
  93. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  94. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  95. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +135 -11
  96. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  97. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -2
  98. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  99. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  100. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +17 -2
  101. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  102. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +1 -1
  103. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  104. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  105. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +39 -6
  106. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  107. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  108. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue.js +1 -1
  109. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js +140 -7
  110. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js.map +1 -1
  111. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue3.js.map +1 -1
  112. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
  113. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js +37 -9
  114. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
  115. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +6 -5
  116. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
  117. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
  118. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
  119. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  120. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  121. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue.js +1 -1
  122. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +2 -1
  123. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
  124. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
  125. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue2.js +53 -1
  126. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue2.js.map +1 -1
  127. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue3.js +3 -2
  128. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue3.js.map +1 -1
  129. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  130. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +135 -0
  131. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  132. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  133. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  134. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +19 -0
  135. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  136. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +37 -19
  137. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  138. package/dist/esm/composables/useListView.js +21 -0
  139. package/dist/esm/composables/useListView.js.map +1 -1
  140. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  141. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
  142. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  143. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
  144. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  145. package/dist/esm/index.js +1 -0
  146. package/dist/esm/index.js.map +1 -1
  147. package/dist/esm/l10n/en.json.js +38 -1
  148. package/dist/esm/l10n/en.json.js.map +1 -1
  149. package/dist/esm/l10n/nl.json.js +78 -1
  150. package/dist/esm/l10n/nl.json.js.map +1 -1
  151. package/dist/esm/library-style.js +1 -1
  152. package/dist/esm/nextcloud-vue.css +1030 -533
  153. package/dist/esm/store/useObjectStore.js +16 -2
  154. package/dist/esm/store/useObjectStore.js.map +1 -1
  155. package/dist/esm/utils/boardColumns.js +8 -1
  156. package/dist/esm/utils/boardColumns.js.map +1 -1
  157. package/dist/esm/utils/boardReferenceColumns.js +278 -0
  158. package/dist/esm/utils/boardReferenceColumns.js.map +1 -0
  159. package/dist/esm/utils/buildManifest.js +41 -4
  160. package/dist/esm/utils/buildManifest.js.map +1 -1
  161. package/dist/esm/utils/detailWidgetVisibility.js +149 -0
  162. package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
  163. package/dist/esm/utils/formatMetric.js +39 -3
  164. package/dist/esm/utils/formatMetric.js.map +1 -1
  165. package/dist/esm/utils/gridStack.js +40 -1
  166. package/dist/esm/utils/gridStack.js.map +1 -1
  167. package/dist/esm/utils/humaniseKey.js +41 -0
  168. package/dist/esm/utils/humaniseKey.js.map +1 -0
  169. package/dist/esm/utils/notificationRuleLabel.js +6 -23
  170. package/dist/esm/utils/notificationRuleLabel.js.map +1 -1
  171. package/dist/esm/utils/resolveManifestTokens.js +1 -0
  172. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  173. package/dist/esm/utils/sentinelTokens.js +9 -0
  174. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  175. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  176. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  177. package/dist/esm/utils/widgetDispatch.js +8 -2
  178. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  179. package/dist/nextcloud-vue.cjs.js +17159 -13422
  180. package/dist/nextcloud-vue.cjs.js.map +2 -2
  181. package/dist/nextcloud-vue.css +1030 -533
  182. package/l10n/en.json +38 -1
  183. package/l10n/nl.json +78 -1
  184. package/package.json +1 -1
  185. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +160 -8
  186. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
  187. package/src/components/CnBoardView/CnBoardView.vue +262 -6
  188. package/src/components/CnBodySections/CnBodySections.vue +22 -1
  189. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
  190. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +244 -8
  191. package/src/components/CnDashboardPage/CnDashboardPage.vue +62 -2
  192. package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
  193. package/src/components/CnDetailPage/CnDetailPage.md +3 -1
  194. package/src/components/CnDetailPage/CnDetailPage.vue +226 -8
  195. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +3 -1
  196. package/src/components/CnFactsWidget/CnFactsWidget.vue +18 -5
  197. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
  198. package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
  199. package/src/components/CnIndexPage/CnIndexPage.vue +586 -25
  200. package/src/components/CnIndexPage/useSelfFetchList.js +10 -1
  201. package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
  202. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  203. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +154 -5
  204. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +106 -3
  205. package/src/components/CnRegisterMapping/CnRegisterMapping.md +5 -0
  206. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +231 -96
  207. package/src/components/CnReportsPage/CnReportsPage.vue +179 -1
  208. package/src/components/CnSettingsPage/CnSettingsPage.vue +5 -1
  209. package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
  210. package/src/components/CnStatWidget/CnStatWidget.vue +137 -12
  211. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +34 -3
  212. package/src/components/CnTabsWidget/CnTabsWidget.vue +39 -6
  213. package/src/components/CnToggleTableWidget/CnToggleTableWidget.vue +140 -7
  214. package/src/components/CnVersionInfoCard/CnVersionInfoCard.md +2 -1
  215. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +42 -13
  216. package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
  217. package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +2 -1
  218. package/src/components/CnWidgetCardGrid/CnWidgetCardGrid.vue +55 -2
  219. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +135 -0
  220. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +10 -0
  221. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +44 -1
  222. package/src/composables/useListView.js +21 -0
  223. package/src/css/board.css +65 -4
  224. package/src/css/dialog.css +4 -2
  225. package/src/css/form-field.css +14 -0
  226. package/src/css/form-widget.css +11 -0
  227. package/src/css/index-page.css +49 -0
  228. package/src/css/kpi-card.css +13 -0
  229. package/src/css/look-board-reports.css +58 -0
  230. package/src/css/look-board.css +108 -7
  231. package/src/dialogs/CnTransitionInputDialog.vue +87 -7
  232. package/src/index.js +2 -0
  233. package/src/schemas/app-manifest-v2.schema.json +137 -6
  234. package/src/store/useObjectStore.js +16 -2
  235. package/src/utils/boardColumns.js +8 -1
  236. package/src/utils/boardReferenceColumns.js +274 -0
  237. package/src/utils/buildManifest.js +40 -3
  238. package/src/utils/detailWidgetVisibility.js +145 -0
  239. package/src/utils/formatMetric.js +38 -2
  240. package/src/utils/gridStack.js +39 -0
  241. package/src/utils/humaniseKey.js +38 -0
  242. package/src/utils/notificationRuleLabel.js +5 -23
  243. package/src/utils/resolveManifestTokens.js +1 -0
  244. package/src/utils/sentinelTokens.js +9 -0
  245. package/src/utils/validateManifestV2.compiled.js +3 -3
  246. package/src/utils/widgetDispatch.js +8 -2
@@ -43,6 +43,18 @@ const VARIANT_COLORS = Object.freeze({
43
43
  * negative number: "-3 days left" is a puzzle, and an overdue case is the one
44
44
  * state a handler must not have to decode.
45
45
  *
46
+ * ## A settled record
47
+ *
48
+ * `settledField` names a property on the record that is truthy once the work
49
+ * the date belongs to is done, such as a case in its final status. A settled
50
+ * record reads `settledLabel` (default "Closed") in no threshold colour, never
51
+ * "overdue": a closed case missed nothing that can still be acted on, and a red
52
+ * count of days on it sends a handler after a case that needs nobody.
53
+ *
54
+ * ```js
55
+ * content: { field: 'deadline', settledField: 'isFinalStatus', settledLabel: 'Handled' }
56
+ * ```
57
+ *
46
58
  * ## An unset date
47
59
  *
48
60
  * Renders a dash and no threshold colour. A case with no deadline is not urgent
@@ -65,8 +77,10 @@ var script = {
65
77
  * - `icon` — MDI icon name.
66
78
  * - `thresholds` — `{ warn, danger }` in days remaining.
67
79
  * - `showDate` — render the absolute date underneath (default true).
80
+ * - `settledField` — property on the record that is truthy once the work is done; the tile then reads `settledLabel` and never counts overdue.
81
+ * - `settledLabel` — the headline of a settled record, run through the host's translate function (default "Closed").
68
82
  *
69
- * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean }}
83
+ * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean, settledField?: string, settledLabel?: string }}
70
84
  */
71
85
  content: {
72
86
  type: Object,
@@ -89,6 +103,7 @@ var script = {
89
103
  return {
90
104
  cnObjectContextInjected: inject('cnObjectContext', null),
91
105
  cnDetailObjectContextInjected: inject('cnDetailObjectContext', null),
106
+ cnTranslate: inject('cnTranslate', null),
92
107
  }
93
108
  },
94
109
 
@@ -140,17 +155,44 @@ var script = {
140
155
  return Math.round((startOfDay(this.targetDate) - startOfDay(new Date())) / MS_PER_DAY)
141
156
  },
142
157
 
143
- /** @return {boolean} true once the date has passed. */
158
+ /**
159
+ * Whether the record says its work is done (`settledField` is truthy).
160
+ *
161
+ * @return {boolean} True for a settled record.
162
+ * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue
163
+ */
164
+ isSettled() {
165
+ const field = this.content?.settledField;
166
+ if (!this.record || !field) {
167
+ return false
168
+ }
169
+ return Boolean(getByPath(this.record, field))
170
+ },
171
+
172
+ /**
173
+ * Whether the date has passed on a record still open.
174
+ *
175
+ * @return {boolean} True when overdue.
176
+ * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue
177
+ */
144
178
  isOverdue() {
145
- return this.daysRemaining !== null && this.daysRemaining < 0
179
+ return !this.isSettled && this.daysRemaining !== null && this.daysRemaining < 0
146
180
  },
147
181
 
148
182
  /**
149
183
  * The headline: the remaining time in words.
150
184
  *
151
185
  * @return {string} The headline text.
186
+ * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue
152
187
  */
153
188
  headline() {
189
+ if (this.isSettled) {
190
+ const label = this.content?.settledLabel;
191
+ if (label) {
192
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(label) : label
193
+ }
194
+ return translate('nextcloud-vue', 'Closed')
195
+ }
154
196
  const days = this.daysRemaining;
155
197
  if (days === null) {
156
198
  return '—'
@@ -187,10 +229,11 @@ var script = {
187
229
  * The threshold band this date falls in.
188
230
  *
189
231
  * @return {string} One of default / success / warning / error.
232
+ * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue
190
233
  */
191
234
  variant() {
192
235
  const days = this.daysRemaining;
193
- if (days === null) {
236
+ if (days === null || this.isSettled) {
194
237
  return 'default'
195
238
  }
196
239
  if (days < 0) {
@@ -1 +1 @@
1
- {"version":3,"file":"CnCountdownWidget.vue2.js","sources":["../../../../src/components/CnCountdownWidget/CnCountdownWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-kpi-card cn-countdown-widget\" :class=\"`cn-countdown-widget--${variant}`\">\n\t\t<div class=\"cn-kpi-card__icon cn-countdown-widget__icon\" :style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body\">\n\t\t\t<div v-if=\"resolvedLabel\" class=\"cn-kpi-card__title\">\n\t\t\t\t{{ resolvedLabel }}\n\t\t\t</div>\n\t\t\t<div class=\"cn-kpi-card__value cn-countdown-widget__value\" :style=\"{ color: valueColor }\">\n\t\t\t\t{{ headline }}\n\t\t\t</div>\n\t\t\t<div v-if=\"subLabel\" class=\"cn-countdown-widget__sub\">\n\t\t\t\t{{ subLabel }}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { inject } from 'vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { getByPath } from '../../composables/useEndpointSource.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000\n\nconst VARIANT_COLORS = Object.freeze({\n\tdefault: 'var(--color-primary-element)',\n\tsuccess: 'var(--color-success)',\n\twarning: 'var(--color-warning)',\n\terror: 'var(--color-error)',\n})\n\n/**\n * CnCountdownWidget — a KPI tile for how long is left until a date on the\n * bound record.\n *\n * Every other tile in the catalog answers \"how many\" by aggregating or calling\n * an endpoint. This one answers \"how long\", and it needs neither: the record is\n * already loaded by the detail page, so the tile reads the date straight off it\n * and does the arithmetic. No request, and nothing to go stale.\n *\n * It headlines the remaining time and carries the date itself underneath,\n * because \"12 days left\" and \"5 October\" answer different questions and a\n * handler asks both.\n *\n * ## Thresholds\n *\n * `thresholds` recolours the tile as the date approaches, in days:\n *\n * ```js\n * content: {\n * label: 'Time left',\n * field: 'deadline',\n * icon: 'ClockAlertOutline',\n * thresholds: { warn: 14, danger: 5 },\n * }\n * ```\n *\n * A date in the past reads \"overdue by N days\" in the error colour, never a\n * negative number: \"-3 days left\" is a puzzle, and an overdue case is the one\n * state a handler must not have to decode.\n *\n * ## An unset date\n *\n * Renders a dash and no threshold colour. A case with no deadline is not urgent\n * and is not on time; it simply has no deadline, and colouring it green would\n * claim otherwise.\n */\nexport default {\n\tname: 'CnCountdownWidget',\n\n\tcomponents: {\n\t\tCnWidgetIcon,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The tile's config.\n\t\t *\n\t\t * - `field` — property on the bound record holding the target date.\n\t\t * - `label` — the tile's title.\n\t\t * - `icon` — MDI icon name.\n\t\t * - `thresholds` — `{ warn, danger }` in days remaining.\n\t\t * - `showDate` — render the absolute date underneath (default true).\n\t\t *\n\t\t * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean }}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it explicitly. Falls back to\n\t\t * the injected `cnObjectContext` a detail page provides.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tsetup() {\n\t\treturn {\n\t\t\tcnObjectContextInjected: inject('cnObjectContext', null),\n\t\t\tcnDetailObjectContextInjected: inject('cnDetailObjectContext', null),\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The record to read the date from.\n\t\t *\n\t\t * @return {object|null} The bound record, or null.\n\t\t */\n\t\trecord() {\n\t\t\tif (this.objectData) {\n\t\t\t\treturn this.objectData\n\t\t\t}\n\t\t\tconst ctx = resolveObjectTokenContext(this.cnObjectContextInjected, this.cnDetailObjectContextInjected)\n\t\t\treturn ctx?.object || null\n\t\t},\n\n\t\t/**\n\t\t * The target date, or null when unset or unparseable.\n\t\t *\n\t\t * @return {Date|null} The parsed date.\n\t\t */\n\t\ttargetDate() {\n\t\t\tconst field = this.content?.field\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\tif (raw === null || raw === undefined || raw === '') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst parsed = new Date(raw)\n\t\t\treturn Number.isNaN(parsed.getTime()) ? null : parsed\n\t\t},\n\n\t\t/**\n\t\t * Whole days from today until the target. Negative once it has passed.\n\t\t *\n\t\t * Both ends are floored to midnight first, so \"tomorrow\" is 1 day away\n\t\t * all day today rather than flipping to 0 at lunchtime.\n\t\t *\n\t\t * @return {number|null} Days remaining, or null.\n\t\t */\n\t\tdaysRemaining() {\n\t\t\tif (!this.targetDate) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst startOfDay = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())\n\t\t\treturn Math.round((startOfDay(this.targetDate) - startOfDay(new Date())) / MS_PER_DAY)\n\t\t},\n\n\t\t/** @return {boolean} true once the date has passed. */\n\t\tisOverdue() {\n\t\t\treturn this.daysRemaining !== null && this.daysRemaining < 0\n\t\t},\n\n\t\t/**\n\t\t * The headline: the remaining time in words.\n\t\t *\n\t\t * @return {string} The headline text.\n\t\t */\n\t\theadline() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn '—'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\tconst overdue = Math.abs(days)\n\t\t\t\treturn overdue === 1\n\t\t\t\t\t? t('nextcloud-vue', '1 day overdue')\n\t\t\t\t\t: t('nextcloud-vue', '{count} days overdue', { count: overdue })\n\t\t\t}\n\t\t\tif (days === 0) {\n\t\t\t\treturn t('nextcloud-vue', 'Due today')\n\t\t\t}\n\t\t\tif (days === 1) {\n\t\t\t\treturn t('nextcloud-vue', '1 day left')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', '{count} days left', { count: days })\n\t\t},\n\n\t\t/**\n\t\t * The absolute date under the headline, so the tile answers \"how long\"\n\t\t * and \"when\" at once.\n\t\t *\n\t\t * @return {string} The formatted date, or ''.\n\t\t */\n\t\tsubLabel() {\n\t\t\tif (!this.targetDate || this.content?.showDate === false) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn this.targetDate.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })\n\t\t},\n\n\t\t/**\n\t\t * The threshold band this date falls in.\n\t\t *\n\t\t * @return {string} One of default / success / warning / error.\n\t\t */\n\t\tvariant() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn 'default'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst thresholds = this.content?.thresholds || {}\n\t\t\tif (Number.isFinite(thresholds.danger) && days <= thresholds.danger) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(thresholds.warn) && days <= thresholds.warn) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/** @return {string} The value colour for the current band. */\n\t\tvalueColor() {\n\t\t\treturn VARIANT_COLORS[this.variant] || VARIANT_COLORS.default\n\t\t},\n\n\t\t/** @return {string} The tile label. */\n\t\tresolvedLabel() {\n\t\t\treturn this.content?.label || ''\n\t\t},\n\n\t\t/** @return {string} The MDI icon name. */\n\t\tresolvedIcon() {\n\t\t\treturn this.content?.icon || 'ClockOutline'\n\t\t},\n\n\t\t/** @return {object} Inline style for the icon circle. */\n\t\ticonCircleStyle() {\n\t\t\treturn { color: this.valueColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-countdown-widget__value {\n\twhite-space: nowrap;\n}\n\n.cn-countdown-widget__sub {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n}\n</style>\n"],"names":["CnWidgetIcon","t"],"mappings":"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"}
1
+ {"version":3,"file":"CnCountdownWidget.vue2.js","sources":["../../../../src/components/CnCountdownWidget/CnCountdownWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-kpi-card cn-countdown-widget\" :class=\"`cn-countdown-widget--${variant}`\">\n\t\t<div class=\"cn-kpi-card__icon cn-countdown-widget__icon\" :style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body\">\n\t\t\t<div v-if=\"resolvedLabel\" class=\"cn-kpi-card__title\">\n\t\t\t\t{{ resolvedLabel }}\n\t\t\t</div>\n\t\t\t<div class=\"cn-kpi-card__value cn-countdown-widget__value\" :style=\"{ color: valueColor }\">\n\t\t\t\t{{ headline }}\n\t\t\t</div>\n\t\t\t<div v-if=\"subLabel\" class=\"cn-countdown-widget__sub\">\n\t\t\t\t{{ subLabel }}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { inject } from 'vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { getByPath } from '../../composables/useEndpointSource.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000\n\nconst VARIANT_COLORS = Object.freeze({\n\tdefault: 'var(--color-primary-element)',\n\tsuccess: 'var(--color-success)',\n\twarning: 'var(--color-warning)',\n\terror: 'var(--color-error)',\n})\n\n/**\n * CnCountdownWidget — a KPI tile for how long is left until a date on the\n * bound record.\n *\n * Every other tile in the catalog answers \"how many\" by aggregating or calling\n * an endpoint. This one answers \"how long\", and it needs neither: the record is\n * already loaded by the detail page, so the tile reads the date straight off it\n * and does the arithmetic. No request, and nothing to go stale.\n *\n * It headlines the remaining time and carries the date itself underneath,\n * because \"12 days left\" and \"5 October\" answer different questions and a\n * handler asks both.\n *\n * ## Thresholds\n *\n * `thresholds` recolours the tile as the date approaches, in days:\n *\n * ```js\n * content: {\n * label: 'Time left',\n * field: 'deadline',\n * icon: 'ClockAlertOutline',\n * thresholds: { warn: 14, danger: 5 },\n * }\n * ```\n *\n * A date in the past reads \"overdue by N days\" in the error colour, never a\n * negative number: \"-3 days left\" is a puzzle, and an overdue case is the one\n * state a handler must not have to decode.\n *\n * ## A settled record\n *\n * `settledField` names a property on the record that is truthy once the work\n * the date belongs to is done, such as a case in its final status. A settled\n * record reads `settledLabel` (default \"Closed\") in no threshold colour, never\n * \"overdue\": a closed case missed nothing that can still be acted on, and a red\n * count of days on it sends a handler after a case that needs nobody.\n *\n * ```js\n * content: { field: 'deadline', settledField: 'isFinalStatus', settledLabel: 'Handled' }\n * ```\n *\n * ## An unset date\n *\n * Renders a dash and no threshold colour. A case with no deadline is not urgent\n * and is not on time; it simply has no deadline, and colouring it green would\n * claim otherwise.\n */\nexport default {\n\tname: 'CnCountdownWidget',\n\n\tcomponents: {\n\t\tCnWidgetIcon,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The tile's config.\n\t\t *\n\t\t * - `field` — property on the bound record holding the target date.\n\t\t * - `label` — the tile's title.\n\t\t * - `icon` — MDI icon name.\n\t\t * - `thresholds` — `{ warn, danger }` in days remaining.\n\t\t * - `showDate` — render the absolute date underneath (default true).\n\t\t * - `settledField` — property on the record that is truthy once the work is done; the tile then reads `settledLabel` and never counts overdue.\n\t\t * - `settledLabel` — the headline of a settled record, run through the host's translate function (default \"Closed\").\n\t\t *\n\t\t * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean, settledField?: string, settledLabel?: string }}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it explicitly. Falls back to\n\t\t * the injected `cnObjectContext` a detail page provides.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tsetup() {\n\t\treturn {\n\t\t\tcnObjectContextInjected: inject('cnObjectContext', null),\n\t\t\tcnDetailObjectContextInjected: inject('cnDetailObjectContext', null),\n\t\t\tcnTranslate: inject('cnTranslate', null),\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The record to read the date from.\n\t\t *\n\t\t * @return {object|null} The bound record, or null.\n\t\t */\n\t\trecord() {\n\t\t\tif (this.objectData) {\n\t\t\t\treturn this.objectData\n\t\t\t}\n\t\t\tconst ctx = resolveObjectTokenContext(this.cnObjectContextInjected, this.cnDetailObjectContextInjected)\n\t\t\treturn ctx?.object || null\n\t\t},\n\n\t\t/**\n\t\t * The target date, or null when unset or unparseable.\n\t\t *\n\t\t * @return {Date|null} The parsed date.\n\t\t */\n\t\ttargetDate() {\n\t\t\tconst field = this.content?.field\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\tif (raw === null || raw === undefined || raw === '') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst parsed = new Date(raw)\n\t\t\treturn Number.isNaN(parsed.getTime()) ? null : parsed\n\t\t},\n\n\t\t/**\n\t\t * Whole days from today until the target. Negative once it has passed.\n\t\t *\n\t\t * Both ends are floored to midnight first, so \"tomorrow\" is 1 day away\n\t\t * all day today rather than flipping to 0 at lunchtime.\n\t\t *\n\t\t * @return {number|null} Days remaining, or null.\n\t\t */\n\t\tdaysRemaining() {\n\t\t\tif (!this.targetDate) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst startOfDay = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())\n\t\t\treturn Math.round((startOfDay(this.targetDate) - startOfDay(new Date())) / MS_PER_DAY)\n\t\t},\n\n\t\t/**\n\t\t * Whether the record says its work is done (`settledField` is truthy).\n\t\t *\n\t\t * @return {boolean} True for a settled record.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tisSettled() {\n\t\t\tconst field = this.content?.settledField\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn Boolean(getByPath(this.record, field))\n\t\t},\n\n\t\t/**\n\t\t * Whether the date has passed on a record still open.\n\t\t *\n\t\t * @return {boolean} True when overdue.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tisOverdue() {\n\t\t\treturn !this.isSettled && this.daysRemaining !== null && this.daysRemaining < 0\n\t\t},\n\n\t\t/**\n\t\t * The headline: the remaining time in words.\n\t\t *\n\t\t * @return {string} The headline text.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\theadline() {\n\t\t\tif (this.isSettled) {\n\t\t\t\tconst label = this.content?.settledLabel\n\t\t\t\tif (label) {\n\t\t\t\t\treturn typeof this.cnTranslate === 'function' ? this.cnTranslate(label) : label\n\t\t\t\t}\n\t\t\t\treturn t('nextcloud-vue', 'Closed')\n\t\t\t}\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn '—'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\tconst overdue = Math.abs(days)\n\t\t\t\treturn overdue === 1\n\t\t\t\t\t? t('nextcloud-vue', '1 day overdue')\n\t\t\t\t\t: t('nextcloud-vue', '{count} days overdue', { count: overdue })\n\t\t\t}\n\t\t\tif (days === 0) {\n\t\t\t\treturn t('nextcloud-vue', 'Due today')\n\t\t\t}\n\t\t\tif (days === 1) {\n\t\t\t\treturn t('nextcloud-vue', '1 day left')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', '{count} days left', { count: days })\n\t\t},\n\n\t\t/**\n\t\t * The absolute date under the headline, so the tile answers \"how long\"\n\t\t * and \"when\" at once.\n\t\t *\n\t\t * @return {string} The formatted date, or ''.\n\t\t */\n\t\tsubLabel() {\n\t\t\tif (!this.targetDate || this.content?.showDate === false) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn this.targetDate.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })\n\t\t},\n\n\t\t/**\n\t\t * The threshold band this date falls in.\n\t\t *\n\t\t * @return {string} One of default / success / warning / error.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tvariant() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null || this.isSettled) {\n\t\t\t\treturn 'default'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst thresholds = this.content?.thresholds || {}\n\t\t\tif (Number.isFinite(thresholds.danger) && days <= thresholds.danger) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(thresholds.warn) && days <= thresholds.warn) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/** @return {string} The value colour for the current band. */\n\t\tvalueColor() {\n\t\t\treturn VARIANT_COLORS[this.variant] || VARIANT_COLORS.default\n\t\t},\n\n\t\t/** @return {string} The tile label. */\n\t\tresolvedLabel() {\n\t\t\treturn this.content?.label || ''\n\t\t},\n\n\t\t/** @return {string} The MDI icon name. */\n\t\tresolvedIcon() {\n\t\t\treturn this.content?.icon || 'ClockOutline'\n\t\t},\n\n\t\t/** @return {object} Inline style for the icon circle. */\n\t\ticonCircleStyle() {\n\t\t\treturn { color: this.valueColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-countdown-widget__value {\n\twhite-space: nowrap;\n}\n\n.cn-countdown-widget__sub {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n}\n</style>\n"],"names":["CnWidgetIcon","t"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"CnCountdownWidget.vue3.js","sources":["../../../../src/components/CnCountdownWidget/CnCountdownWidget.vue?vue&type=template&id=17598258&lang.js"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-kpi-card cn-countdown-widget\" :class=\"`cn-countdown-widget--${variant}`\">\n\t\t<div class=\"cn-kpi-card__icon cn-countdown-widget__icon\" :style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body\">\n\t\t\t<div v-if=\"resolvedLabel\" class=\"cn-kpi-card__title\">\n\t\t\t\t{{ resolvedLabel }}\n\t\t\t</div>\n\t\t\t<div class=\"cn-kpi-card__value cn-countdown-widget__value\" :style=\"{ color: valueColor }\">\n\t\t\t\t{{ headline }}\n\t\t\t</div>\n\t\t\t<div v-if=\"subLabel\" class=\"cn-countdown-widget__sub\">\n\t\t\t\t{{ subLabel }}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { inject } from 'vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { getByPath } from '../../composables/useEndpointSource.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000\n\nconst VARIANT_COLORS = Object.freeze({\n\tdefault: 'var(--color-primary-element)',\n\tsuccess: 'var(--color-success)',\n\twarning: 'var(--color-warning)',\n\terror: 'var(--color-error)',\n})\n\n/**\n * CnCountdownWidget — a KPI tile for how long is left until a date on the\n * bound record.\n *\n * Every other tile in the catalog answers \"how many\" by aggregating or calling\n * an endpoint. This one answers \"how long\", and it needs neither: the record is\n * already loaded by the detail page, so the tile reads the date straight off it\n * and does the arithmetic. No request, and nothing to go stale.\n *\n * It headlines the remaining time and carries the date itself underneath,\n * because \"12 days left\" and \"5 October\" answer different questions and a\n * handler asks both.\n *\n * ## Thresholds\n *\n * `thresholds` recolours the tile as the date approaches, in days:\n *\n * ```js\n * content: {\n * label: 'Time left',\n * field: 'deadline',\n * icon: 'ClockAlertOutline',\n * thresholds: { warn: 14, danger: 5 },\n * }\n * ```\n *\n * A date in the past reads \"overdue by N days\" in the error colour, never a\n * negative number: \"-3 days left\" is a puzzle, and an overdue case is the one\n * state a handler must not have to decode.\n *\n * ## An unset date\n *\n * Renders a dash and no threshold colour. A case with no deadline is not urgent\n * and is not on time; it simply has no deadline, and colouring it green would\n * claim otherwise.\n */\nexport default {\n\tname: 'CnCountdownWidget',\n\n\tcomponents: {\n\t\tCnWidgetIcon,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The tile's config.\n\t\t *\n\t\t * - `field` — property on the bound record holding the target date.\n\t\t * - `label` — the tile's title.\n\t\t * - `icon` — MDI icon name.\n\t\t * - `thresholds` — `{ warn, danger }` in days remaining.\n\t\t * - `showDate` — render the absolute date underneath (default true).\n\t\t *\n\t\t * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean }}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it explicitly. Falls back to\n\t\t * the injected `cnObjectContext` a detail page provides.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tsetup() {\n\t\treturn {\n\t\t\tcnObjectContextInjected: inject('cnObjectContext', null),\n\t\t\tcnDetailObjectContextInjected: inject('cnDetailObjectContext', null),\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The record to read the date from.\n\t\t *\n\t\t * @return {object|null} The bound record, or null.\n\t\t */\n\t\trecord() {\n\t\t\tif (this.objectData) {\n\t\t\t\treturn this.objectData\n\t\t\t}\n\t\t\tconst ctx = resolveObjectTokenContext(this.cnObjectContextInjected, this.cnDetailObjectContextInjected)\n\t\t\treturn ctx?.object || null\n\t\t},\n\n\t\t/**\n\t\t * The target date, or null when unset or unparseable.\n\t\t *\n\t\t * @return {Date|null} The parsed date.\n\t\t */\n\t\ttargetDate() {\n\t\t\tconst field = this.content?.field\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\tif (raw === null || raw === undefined || raw === '') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst parsed = new Date(raw)\n\t\t\treturn Number.isNaN(parsed.getTime()) ? null : parsed\n\t\t},\n\n\t\t/**\n\t\t * Whole days from today until the target. Negative once it has passed.\n\t\t *\n\t\t * Both ends are floored to midnight first, so \"tomorrow\" is 1 day away\n\t\t * all day today rather than flipping to 0 at lunchtime.\n\t\t *\n\t\t * @return {number|null} Days remaining, or null.\n\t\t */\n\t\tdaysRemaining() {\n\t\t\tif (!this.targetDate) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst startOfDay = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())\n\t\t\treturn Math.round((startOfDay(this.targetDate) - startOfDay(new Date())) / MS_PER_DAY)\n\t\t},\n\n\t\t/** @return {boolean} true once the date has passed. */\n\t\tisOverdue() {\n\t\t\treturn this.daysRemaining !== null && this.daysRemaining < 0\n\t\t},\n\n\t\t/**\n\t\t * The headline: the remaining time in words.\n\t\t *\n\t\t * @return {string} The headline text.\n\t\t */\n\t\theadline() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn '—'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\tconst overdue = Math.abs(days)\n\t\t\t\treturn overdue === 1\n\t\t\t\t\t? t('nextcloud-vue', '1 day overdue')\n\t\t\t\t\t: t('nextcloud-vue', '{count} days overdue', { count: overdue })\n\t\t\t}\n\t\t\tif (days === 0) {\n\t\t\t\treturn t('nextcloud-vue', 'Due today')\n\t\t\t}\n\t\t\tif (days === 1) {\n\t\t\t\treturn t('nextcloud-vue', '1 day left')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', '{count} days left', { count: days })\n\t\t},\n\n\t\t/**\n\t\t * The absolute date under the headline, so the tile answers \"how long\"\n\t\t * and \"when\" at once.\n\t\t *\n\t\t * @return {string} The formatted date, or ''.\n\t\t */\n\t\tsubLabel() {\n\t\t\tif (!this.targetDate || this.content?.showDate === false) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn this.targetDate.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })\n\t\t},\n\n\t\t/**\n\t\t * The threshold band this date falls in.\n\t\t *\n\t\t * @return {string} One of default / success / warning / error.\n\t\t */\n\t\tvariant() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn 'default'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst thresholds = this.content?.thresholds || {}\n\t\t\tif (Number.isFinite(thresholds.danger) && days <= thresholds.danger) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(thresholds.warn) && days <= thresholds.warn) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/** @return {string} The value colour for the current band. */\n\t\tvalueColor() {\n\t\t\treturn VARIANT_COLORS[this.variant] || VARIANT_COLORS.default\n\t\t},\n\n\t\t/** @return {string} The tile label. */\n\t\tresolvedLabel() {\n\t\t\treturn this.content?.label || ''\n\t\t},\n\n\t\t/** @return {string} The MDI icon name. */\n\t\tresolvedIcon() {\n\t\t\treturn this.content?.icon || 'ClockOutline'\n\t\t},\n\n\t\t/** @return {object} Inline style for the icon circle. */\n\t\ticonCircleStyle() {\n\t\t\treturn { color: this.valueColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-countdown-widget__value {\n\twhite-space: nowrap;\n}\n\n.cn-countdown-widget__sub {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n}\n</style>\n"],"names":["_normalizeClass","_createVNode","_createElementBlock","_toDisplayString","_createElementVNode"],"mappings":";;AAgBO,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,mBAAI,GAAA;;;AACT,EAAA,KAAA,EAAA,oBAAA;;;;;;;;;;;IANH,KAAM,EAAAA,cAAA,CAAA,CAAC,iCAAoB,EAAA,CAAA,qBAAA,EAAA,QAAA,CAAA,OAAA,CAAA,CAAA,CAAA,CAAA;;sBAGxB,CAAA,KAAA,EAAA;AAFA,MAAA,KAAI,EAAC,6CAA0C;AAAA,MAAA,KAAA,iBAAA,QAAA,CAAA,eAAA,CAAA;;MACnDC,WAAkB,CAAA,uBAAA,EAAA;AAAJ,QAAA,IAAI,EAAA,QAAA,CAAA,YAAA;AAAA,QAAA,IAAA,EAAA,EAAA;;;;OAId,QAAA,CAAA,aAAA;wBAALC,kBAEM,CAAA,KAAA,EAFN,UAEM,EAAAC,eAAA,CADD,QAAY,CAAA,aAAA,CAAA,EAAA,CAAA,YAAA;;MAEjBC,kBAEG,CAAA,KAAA,EAAA;AAFE,QAAA,KAAA,EAAA,+CAAA;AAAA,QAAA,KAAA,0BAAA,QAAA,CAAA,UAAA,EAAA,CAAA;yBACD,QAAA,CAAA,QAAA,CAAA,EAAA,CAAA,mBAAA;;;;;;;;;;"}
1
+ {"version":3,"file":"CnCountdownWidget.vue3.js","sources":["../../../../src/components/CnCountdownWidget/CnCountdownWidget.vue?vue&type=template&id=5546e71c&lang.js"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-kpi-card cn-countdown-widget\" :class=\"`cn-countdown-widget--${variant}`\">\n\t\t<div class=\"cn-kpi-card__icon cn-countdown-widget__icon\" :style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body\">\n\t\t\t<div v-if=\"resolvedLabel\" class=\"cn-kpi-card__title\">\n\t\t\t\t{{ resolvedLabel }}\n\t\t\t</div>\n\t\t\t<div class=\"cn-kpi-card__value cn-countdown-widget__value\" :style=\"{ color: valueColor }\">\n\t\t\t\t{{ headline }}\n\t\t\t</div>\n\t\t\t<div v-if=\"subLabel\" class=\"cn-countdown-widget__sub\">\n\t\t\t\t{{ subLabel }}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { inject } from 'vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { getByPath } from '../../composables/useEndpointSource.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000\n\nconst VARIANT_COLORS = Object.freeze({\n\tdefault: 'var(--color-primary-element)',\n\tsuccess: 'var(--color-success)',\n\twarning: 'var(--color-warning)',\n\terror: 'var(--color-error)',\n})\n\n/**\n * CnCountdownWidget — a KPI tile for how long is left until a date on the\n * bound record.\n *\n * Every other tile in the catalog answers \"how many\" by aggregating or calling\n * an endpoint. This one answers \"how long\", and it needs neither: the record is\n * already loaded by the detail page, so the tile reads the date straight off it\n * and does the arithmetic. No request, and nothing to go stale.\n *\n * It headlines the remaining time and carries the date itself underneath,\n * because \"12 days left\" and \"5 October\" answer different questions and a\n * handler asks both.\n *\n * ## Thresholds\n *\n * `thresholds` recolours the tile as the date approaches, in days:\n *\n * ```js\n * content: {\n * label: 'Time left',\n * field: 'deadline',\n * icon: 'ClockAlertOutline',\n * thresholds: { warn: 14, danger: 5 },\n * }\n * ```\n *\n * A date in the past reads \"overdue by N days\" in the error colour, never a\n * negative number: \"-3 days left\" is a puzzle, and an overdue case is the one\n * state a handler must not have to decode.\n *\n * ## A settled record\n *\n * `settledField` names a property on the record that is truthy once the work\n * the date belongs to is done, such as a case in its final status. A settled\n * record reads `settledLabel` (default \"Closed\") in no threshold colour, never\n * \"overdue\": a closed case missed nothing that can still be acted on, and a red\n * count of days on it sends a handler after a case that needs nobody.\n *\n * ```js\n * content: { field: 'deadline', settledField: 'isFinalStatus', settledLabel: 'Handled' }\n * ```\n *\n * ## An unset date\n *\n * Renders a dash and no threshold colour. A case with no deadline is not urgent\n * and is not on time; it simply has no deadline, and colouring it green would\n * claim otherwise.\n */\nexport default {\n\tname: 'CnCountdownWidget',\n\n\tcomponents: {\n\t\tCnWidgetIcon,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The tile's config.\n\t\t *\n\t\t * - `field` — property on the bound record holding the target date.\n\t\t * - `label` — the tile's title.\n\t\t * - `icon` — MDI icon name.\n\t\t * - `thresholds` — `{ warn, danger }` in days remaining.\n\t\t * - `showDate` — render the absolute date underneath (default true).\n\t\t * - `settledField` — property on the record that is truthy once the work is done; the tile then reads `settledLabel` and never counts overdue.\n\t\t * - `settledLabel` — the headline of a settled record, run through the host's translate function (default \"Closed\").\n\t\t *\n\t\t * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean, settledField?: string, settledLabel?: string }}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it explicitly. Falls back to\n\t\t * the injected `cnObjectContext` a detail page provides.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tsetup() {\n\t\treturn {\n\t\t\tcnObjectContextInjected: inject('cnObjectContext', null),\n\t\t\tcnDetailObjectContextInjected: inject('cnDetailObjectContext', null),\n\t\t\tcnTranslate: inject('cnTranslate', null),\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The record to read the date from.\n\t\t *\n\t\t * @return {object|null} The bound record, or null.\n\t\t */\n\t\trecord() {\n\t\t\tif (this.objectData) {\n\t\t\t\treturn this.objectData\n\t\t\t}\n\t\t\tconst ctx = resolveObjectTokenContext(this.cnObjectContextInjected, this.cnDetailObjectContextInjected)\n\t\t\treturn ctx?.object || null\n\t\t},\n\n\t\t/**\n\t\t * The target date, or null when unset or unparseable.\n\t\t *\n\t\t * @return {Date|null} The parsed date.\n\t\t */\n\t\ttargetDate() {\n\t\t\tconst field = this.content?.field\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\tif (raw === null || raw === undefined || raw === '') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst parsed = new Date(raw)\n\t\t\treturn Number.isNaN(parsed.getTime()) ? null : parsed\n\t\t},\n\n\t\t/**\n\t\t * Whole days from today until the target. Negative once it has passed.\n\t\t *\n\t\t * Both ends are floored to midnight first, so \"tomorrow\" is 1 day away\n\t\t * all day today rather than flipping to 0 at lunchtime.\n\t\t *\n\t\t * @return {number|null} Days remaining, or null.\n\t\t */\n\t\tdaysRemaining() {\n\t\t\tif (!this.targetDate) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst startOfDay = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())\n\t\t\treturn Math.round((startOfDay(this.targetDate) - startOfDay(new Date())) / MS_PER_DAY)\n\t\t},\n\n\t\t/**\n\t\t * Whether the record says its work is done (`settledField` is truthy).\n\t\t *\n\t\t * @return {boolean} True for a settled record.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tisSettled() {\n\t\t\tconst field = this.content?.settledField\n\t\t\tif (!this.record || !field) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn Boolean(getByPath(this.record, field))\n\t\t},\n\n\t\t/**\n\t\t * Whether the date has passed on a record still open.\n\t\t *\n\t\t * @return {boolean} True when overdue.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tisOverdue() {\n\t\t\treturn !this.isSettled && this.daysRemaining !== null && this.daysRemaining < 0\n\t\t},\n\n\t\t/**\n\t\t * The headline: the remaining time in words.\n\t\t *\n\t\t * @return {string} The headline text.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\theadline() {\n\t\t\tif (this.isSettled) {\n\t\t\t\tconst label = this.content?.settledLabel\n\t\t\t\tif (label) {\n\t\t\t\t\treturn typeof this.cnTranslate === 'function' ? this.cnTranslate(label) : label\n\t\t\t\t}\n\t\t\t\treturn t('nextcloud-vue', 'Closed')\n\t\t\t}\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null) {\n\t\t\t\treturn '—'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\tconst overdue = Math.abs(days)\n\t\t\t\treturn overdue === 1\n\t\t\t\t\t? t('nextcloud-vue', '1 day overdue')\n\t\t\t\t\t: t('nextcloud-vue', '{count} days overdue', { count: overdue })\n\t\t\t}\n\t\t\tif (days === 0) {\n\t\t\t\treturn t('nextcloud-vue', 'Due today')\n\t\t\t}\n\t\t\tif (days === 1) {\n\t\t\t\treturn t('nextcloud-vue', '1 day left')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', '{count} days left', { count: days })\n\t\t},\n\n\t\t/**\n\t\t * The absolute date under the headline, so the tile answers \"how long\"\n\t\t * and \"when\" at once.\n\t\t *\n\t\t * @return {string} The formatted date, or ''.\n\t\t */\n\t\tsubLabel() {\n\t\t\tif (!this.targetDate || this.content?.showDate === false) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn this.targetDate.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })\n\t\t},\n\n\t\t/**\n\t\t * The threshold band this date falls in.\n\t\t *\n\t\t * @return {string} One of default / success / warning / error.\n\t\t * @spec openspec/changes/countdown-settled-record/specs/countdown-settled-record/spec.md#requirement-a-settled-record-never-reads-as-overdue\n\t\t */\n\t\tvariant() {\n\t\t\tconst days = this.daysRemaining\n\t\t\tif (days === null || this.isSettled) {\n\t\t\t\treturn 'default'\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst thresholds = this.content?.thresholds || {}\n\t\t\tif (Number.isFinite(thresholds.danger) && days <= thresholds.danger) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(thresholds.warn) && days <= thresholds.warn) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/** @return {string} The value colour for the current band. */\n\t\tvalueColor() {\n\t\t\treturn VARIANT_COLORS[this.variant] || VARIANT_COLORS.default\n\t\t},\n\n\t\t/** @return {string} The tile label. */\n\t\tresolvedLabel() {\n\t\t\treturn this.content?.label || ''\n\t\t},\n\n\t\t/** @return {string} The MDI icon name. */\n\t\tresolvedIcon() {\n\t\t\treturn this.content?.icon || 'ClockOutline'\n\t\t},\n\n\t\t/** @return {object} Inline style for the icon circle. */\n\t\ticonCircleStyle() {\n\t\t\treturn { color: this.valueColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-countdown-widget__value {\n\twhite-space: nowrap;\n}\n\n.cn-countdown-widget__sub {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n}\n</style>\n"],"names":["_normalizeClass","_createVNode","_createElementBlock","_toDisplayString","_createElementVNode"],"mappings":";;AAgBO,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,mBAAI,GAAA;;;AACT,EAAA,KAAA,EAAA,oBAAA;;;;;;;;;;;IANH,KAAM,EAAAA,cAAA,CAAA,CAAC,iCAAoB,EAAA,CAAA,qBAAA,EAAA,QAAA,CAAA,OAAA,CAAA,CAAA,CAAA,CAAA;;sBAGxB,CAAA,KAAA,EAAA;AAFA,MAAA,KAAI,EAAC,6CAA0C;AAAA,MAAA,KAAA,iBAAA,QAAA,CAAA,eAAA,CAAA;;MACnDC,WAAkB,CAAA,uBAAA,EAAA;AAAJ,QAAA,IAAI,EAAA,QAAA,CAAA,YAAA;AAAA,QAAA,IAAA,EAAA,EAAA;;;;OAId,QAAA,CAAA,aAAA;wBAALC,kBAEM,CAAA,KAAA,EAFN,UAEM,EAAAC,eAAA,CADD,QAAY,CAAA,aAAA,CAAA,EAAA,CAAA,YAAA;;MAEjBC,kBAEG,CAAA,KAAA,EAAA;AAFE,QAAA,KAAA,EAAA,+CAAA;AAAA,QAAA,KAAA,0BAAA,QAAA,CAAA,UAAA,EAAA,CAAA;yBACD,QAAA,CAAA,QAAA,CAAA,EAAA,CAAA,mBAAA;;;;;;;;;;"}
@@ -2,7 +2,7 @@ import script from './CnDashboardGrid.vue2.js';
2
2
  import { render } from './CnDashboardGrid.vue3.js';
3
3
 
4
4
  script.render = render;
5
- script.__scopeId = "data-v-29653a76";
5
+ script.__scopeId = "data-v-a91998fc";
6
6
  const __sfc_main = script;
7
7
 
8
8
  export { __sfc_main as default };
@@ -1,5 +1,6 @@
1
1
  import { translate } from '@nextcloud/l10n';
2
2
  import { GridStack } from 'gridstack';
3
+ import { planGrowth } from '../../utils/gridStack.js';
3
4
 
4
5
  // `gridstack` is a peerDependency (see package.json), not bundled — this
5
6
  // component's JS and the consumer's GridStack stylesheet MUST come from the
@@ -29,6 +30,24 @@ let domIdCounter = 0;
29
30
  */
30
31
  const MENU_ANCHOR_OFFSET = 8;
31
32
 
33
+ /**
34
+ * How many GridStack rows a layout row is drawn as on a grid with
35
+ * size-to-content cells (`wantsRowSplit`): 80px rows become 10px rows.
36
+ */
37
+ const ROW_SPLIT = 8;
38
+
39
+ /**
40
+ * Whether a layout holds a size-to-content cell (`sizeToContent` true or a
41
+ * positive row maximum).
42
+ *
43
+ * @param {Array<object>} layout The layout items.
44
+ * @return {boolean} True when one does.
45
+ */
46
+ function hasFitCell(layout) {
47
+ return Array.isArray(layout) && layout.some((item) => item
48
+ && (item.sizeToContent === true || (Number.isFinite(item.sizeToContent) && item.sizeToContent > 0)))
49
+ }
50
+
32
51
  /**
33
52
  * CnDashboardGrid — Low-level grid layout engine powered by GridStack.
34
53
  *
@@ -234,10 +253,62 @@ var script = {
234
253
  * on that.
235
254
  */
236
255
  reflowed: false,
256
+ /**
257
+ * How many GridStack rows one layout row is drawn as: 1, or
258
+ * ROW_SPLIT while `wantsRowSplit` holds. Set before the first render,
259
+ * since GridStack reads the items' places from their attributes,
260
+ * and again when the grid is rebuilt.
261
+ */
262
+ rowSplit: (!this.editable && !this.float && hasFitCell(this.layout)) ? ROW_SPLIT : 1,
237
263
  }
238
264
  },
239
265
 
240
266
  computed: {
267
+ /**
268
+ * The layout's items top to bottom, then left to right: the order the
269
+ * items are in the page, so GridStack places them in that order.
270
+ *
271
+ * GridStack sizes a size-to-content cell while it adds the items, one
272
+ * at a time, and an item it adds onto a cell that has already grown
273
+ * moves itself below whatever it then collides with. In the layout's
274
+ * own order a card declared after the lists (pipelinq's full-width
275
+ * "client in focus" card) was added last, so when the banner above
276
+ * it grew it skipped past the stat tiles AND the lists and landed at
277
+ * the bottom. Added top to bottom, every card lands under the one
278
+ * above it. It is also the order a screen reader and the Tab key
279
+ * follow. An item without a row or column sorts after the items
280
+ * that have one, in the layout's order.
281
+ *
282
+ * @spec openspec/changes/dashboard-growth-keeps-order/specs/dashboard-grid/spec.md#requirement-a-growing-cell-keeps-the-order-of-the-cards-under-it
283
+ * @return {Array<object>} The items in row order.
284
+ */
285
+ itemsInRowOrder() {
286
+ const items = Array.isArray(this.layout) ? [...this.layout] : [];
287
+ const at = (value) => (Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER);
288
+ return items.sort((a, b) => (at(a.gridY) - at(b.gridY)) || (at(a.gridX) - at(b.gridX)))
289
+ },
290
+
291
+ /**
292
+ * Whether the grid draws each layout row as ROW_SPLIT finer rows. A
293
+ * size-to-content cell can only be a whole number of rows tall, so on
294
+ * 80px rows it kept up to a row of empty space under its content and
295
+ * a packing grid (`float: false`) could not close it (pipelinq and
296
+ * dossiq Mijn werk, decidiq Mijn acties). Split into 10px rows the
297
+ * cell ends within 10px of its content and the widgets below move up.
298
+ *
299
+ * Only in view mode, only on a packing grid with a size-to-content
300
+ * cell: every other grid is built exactly as before. Item positions
301
+ * and sizes scale with the rows, so every widget keeps its pixel
302
+ * place and size until a cell shrinks; the layout itself, what edit
303
+ * mode shows and what `layout-change` reports, stays in layout rows.
304
+ *
305
+ * @spec openspec/changes/dashboard-fit-cells-close-up/specs/dashboard-grid/spec.md#requirement-a-size-to-content-cell-ends-at-its-content
306
+ * @return {boolean} True to split the rows.
307
+ */
308
+ wantsRowSplit() {
309
+ return !this.editable && !this.float && hasFitCell(this.layout)
310
+ },
311
+
241
312
  /**
242
313
  * @return {boolean} Whether the keyboard lane is live — items are
243
314
  * focusable and the arrow keys reposition. Requires the opt-in prop,
@@ -304,6 +375,12 @@ var script = {
304
375
  this.syncInteractivity();
305
376
  },
306
377
 
378
+ wantsRowSplit(wants) {
379
+ if (this.grid && (wants ? ROW_SPLIT : 1) !== this.rowSplit) {
380
+ this.rebuildGrid();
381
+ }
382
+ },
383
+
307
384
  layout: {
308
385
  deep: true,
309
386
  handler(newLayout) {
@@ -363,11 +440,75 @@ var script = {
363
440
  }
364
441
  // A number is GridStack's "size to content, but never taller than N rows" (a maximum).
365
442
  if (Number.isFinite(v) && v > 0) {
366
- return String(v)
443
+ return String(v * this.rowSplit)
367
444
  }
368
445
  return undefined
369
446
  },
370
447
 
448
+ /**
449
+ * A layout row coordinate or height in GridStack rows (`rowSplit`).
450
+ *
451
+ * @spec openspec/changes/dashboard-fit-cells-close-up/specs/dashboard-grid/spec.md#requirement-a-size-to-content-cell-ends-at-its-content
452
+ * @param {number|undefined} value The value in layout rows.
453
+ * @return {number|undefined} The value in grid rows, or undefined when none is set.
454
+ */
455
+ scaledRow(value) {
456
+ return Number.isFinite(value) ? value * this.rowSplit : undefined
457
+ },
458
+
459
+ /**
460
+ * The smallest height an item may take, in grid rows: `minHeight`
461
+ * layout rows, except for a size-to-content cell on split rows, which
462
+ * may shrink to its content down to one grid row. The minimum is for
463
+ * resizing by hand, and that only happens on whole rows in edit mode.
464
+ *
465
+ * @spec openspec/changes/dashboard-fit-cells-close-up/specs/dashboard-grid/spec.md#requirement-a-size-to-content-cell-ends-at-its-content
466
+ * @param {object} item The layout item.
467
+ * @return {number} The minimum height in grid rows.
468
+ */
469
+ minRows(item) {
470
+ if (this.rowSplit !== 1 && this.sizeToContentAttr(item) !== undefined) {
471
+ return 1
472
+ }
473
+ return this.minHeight * this.rowSplit
474
+ },
475
+
476
+ /**
477
+ * Build the grid again with the row split `wantsRowSplit` asks for, as
478
+ * edit mode turns on or off: the items go back to their layout places in
479
+ * the new rows, and the size-to-content cells are measured again.
480
+ *
481
+ * @spec openspec/changes/dashboard-fit-cells-close-up/specs/dashboard-grid/spec.md#requirement-a-size-to-content-cell-ends-at-its-content
482
+ * @return {void}
483
+ */
484
+ rebuildGrid() {
485
+ this.disconnectContentObserver();
486
+ this.grid.destroy(false);
487
+ this.grid = null;
488
+ this.rowSplit = this.wantsRowSplit ? ROW_SPLIT : 1;
489
+ this.$nextTick(() => {
490
+ if (!this.$refs.gridContainer) {
491
+ return
492
+ }
493
+ // GridStack wrote its own places onto the items; put the layout's back.
494
+ for (const item of this.layout) {
495
+ const el = this.$refs.gridContainer.querySelector(`[gs-id="${item.id}"]`);
496
+ if (!el) {
497
+ continue
498
+ }
499
+ for (const [name, value] of [['gs-y', this.scaledRow(item.gridY)], ['gs-h', this.scaledRow(item.gridHeight)], ['gs-min-h', this.minRows(item)], ['gs-size-to-content', this.sizeToContentAttr(item)]]) {
500
+ if (value === undefined) {
501
+ el.removeAttribute(name);
502
+ } else {
503
+ el.setAttribute(name, String(value));
504
+ }
505
+ }
506
+ }
507
+ this.initGrid();
508
+ this.observeContentSizes();
509
+ });
510
+ },
511
+
371
512
  /**
372
513
  * Build the accessible name for a grid item. A custom `itemLabel` wins
373
514
  * outright; otherwise the first human-readable field on the item is used
@@ -422,6 +563,13 @@ var script = {
422
563
  )
423
564
  },
424
565
 
566
+ /**
567
+ * Build the GridStack grid on the rendered items, install the growth
568
+ * wrapper (`keepOrderOnGrowth`) and listen for its changes.
569
+ *
570
+ * @spec openspec/changes/dashboard-growth-keeps-order/specs/dashboard-grid/spec.md#requirement-a-growing-cell-keeps-the-order-of-the-cards-under-it
571
+ * @return {void}
572
+ */
425
573
  initGrid() {
426
574
  if (this.cellHeightCssVar && typeof document !== 'undefined' && document.documentElement) {
427
575
  document.documentElement.style.setProperty(this.cellHeightCssVar, `${this.cellHeight}px`);
@@ -429,7 +577,7 @@ var script = {
429
577
  const el = this.$refs.gridContainer.querySelector('.grid-stack');
430
578
  this.grid = GridStack.init({
431
579
  column: this.columns,
432
- cellHeight: this.cellHeight,
580
+ cellHeight: this.cellHeight / this.rowSplit,
433
581
  margin: this.margin,
434
582
  float: this.float,
435
583
  animate: true,
@@ -438,6 +586,7 @@ var script = {
438
586
  removable: false,
439
587
  ...(this.columnOpts ? { columnOpts: this.columnOpts } : {}),
440
588
  }, el);
589
+ this.keepOrderOnGrowth();
441
590
 
442
591
  this.grid.on('change', (_event, items) => {
443
592
  this.handleGridChange(items);
@@ -449,6 +598,87 @@ var script = {
449
598
  this.syncInteractivity();
450
599
  },
451
600
 
601
+ /**
602
+ * Let a size-to-content cell that grows push the cards under it down in
603
+ * their order. GridStack resolves that collision itself, but not in
604
+ * order: when pipelinq's banner grew from two rows to three, the
605
+ * full-width card under the stat tiles landed below the lists, also
606
+ * with `float: false`. Every grow after the grid is built, whether
607
+ * GridStack measures it on resize or this grid's content observer asks
608
+ * for it, now first moves the cards the growth reaches down by the
609
+ * growth (`planGrowth`), bottom-most first, and only then grows the
610
+ * cell, so GridStack has no collision left to resolve. The wrapper is
611
+ * installed after `GridStack.init` has already measured the cells on
612
+ * load; the order on load comes from adding the items in row order
613
+ * (`itemsInRowOrder`). A cell that shrinks is left to GridStack as
614
+ * before.
615
+ *
616
+ * @spec openspec/changes/dashboard-growth-keeps-order/specs/dashboard-grid/spec.md#requirement-a-growing-cell-keeps-the-order-of-the-cards-under-it
617
+ * @return {void}
618
+ */
619
+ keepOrderOnGrowth() {
620
+ const grid = this.grid;
621
+ if (!grid || typeof grid.resizeToContent !== 'function' || typeof grid.update !== 'function') {
622
+ return
623
+ }
624
+ const resize = grid.resizeToContent.bind(grid);
625
+ grid.resizeToContent = (el) => {
626
+ const node = el && el.gridstackNode;
627
+ const nodes = grid.engine && Array.isArray(grid.engine.nodes) ? grid.engine.nodes : null;
628
+ const rows = node && nodes ? this.contentRows(el, node) : null;
629
+ const plan = rows ? planGrowth(nodes, node, rows) : [];
630
+ if (plan.length === 0) {
631
+ resize(el);
632
+ return
633
+ }
634
+ // Inside a batch the moves are not packed until the cell has grown.
635
+ const batching = Boolean(grid.engine.batchMode);
636
+ if (!batching) {
637
+ grid.batchUpdate();
638
+ }
639
+ for (const step of plan) {
640
+ grid.update(step.node.el, { y: step.y });
641
+ }
642
+ resize(el);
643
+ if (!batching) {
644
+ grid.batchUpdate(false);
645
+ }
646
+ };
647
+ },
648
+
649
+ /**
650
+ * The rows a size-to-content cell needs for its content, measured the
651
+ * way GridStack's `resizeToContent` measures it (the content's first
652
+ * child, plus the cell's own padding), within its size-to-content row
653
+ * maximum and its minimum and maximum height.
654
+ *
655
+ * @spec openspec/changes/dashboard-growth-keeps-order/specs/dashboard-grid/spec.md#requirement-a-growing-cell-keeps-the-order-of-the-cards-under-it
656
+ * @param {HTMLElement} el The cell.
657
+ * @param {object} node Its GridStack node.
658
+ * @return {number|null} The rows, or null when it cannot be measured.
659
+ */
660
+ contentRows(el, node) {
661
+ const cell = this.grid.getCellHeight(true);
662
+ const item = el.querySelector('.grid-stack-item-content');
663
+ const child = item && item.firstElementChild;
664
+ if (!el.clientHeight || !cell || !child || !Number.isFinite(node.h)) {
665
+ return null
666
+ }
667
+ const padding = el.clientHeight - item.clientHeight;
668
+ const itemHeight = node.h * cell - padding;
669
+ const wanted = child.getBoundingClientRect().height || itemHeight;
670
+ let rows = Math.ceil((node.h * cell + wanted - itemHeight) / cell);
671
+ if (Number.isInteger(node.sizeToContent) && rows > node.sizeToContent) {
672
+ rows = node.sizeToContent;
673
+ }
674
+ if (node.minH && rows < node.minH) {
675
+ rows = node.minH;
676
+ } else if (node.maxH && rows > node.maxH) {
677
+ rows = node.maxH;
678
+ }
679
+ return rows
680
+ },
681
+
452
682
  /**
453
683
  * Keep both editing lanes in step with whether an edit could actually be
454
684
  * kept: the pointer's handles, and — through `reflowed` feeding
@@ -516,6 +746,12 @@ var script = {
516
746
  if (this.isReflowed()) {
517
747
  return
518
748
  }
749
+ // A grid on split rows is a view-mode grid: what changes in it is
750
+ // packing and size-to-content measuring, never a user's
751
+ // arrangement, and it is not reported as one.
752
+ if (this.rowSplit !== 1) {
753
+ return
754
+ }
519
755
  if (!items || items.length === 0) {
520
756
  return
521
757
  }
@@ -916,7 +1152,7 @@ var script = {
916
1152
  // grid's own position back, which matches and does
917
1153
  // nothing here.
918
1154
  const x = item.gridX ?? node.x;
919
- const y = item.gridY ?? node.y;
1155
+ const y = Number.isFinite(item.gridY) ? item.gridY * this.rowSplit : node.y;
920
1156
  if (x !== node.x || y !== node.y) {
921
1157
  // The layout said where it goes; GridStack's `change`
922
1158
  // for it is not a user's arrangement and is not