github-issue-tower-defence-management 1.142.0 → 1.142.2

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/.github/workflows/test.yml +1 -0
  2. package/CHANGELOG.md +14 -0
  3. package/bin/adapter/entry-points/console/ui-dist/assets/index-2c5E3tdu.js +81 -0
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-BaGbcbYI.css +2 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js +10 -2
  7. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js.map +1 -1
  8. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +18 -14
  9. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
  10. package/package.json +40 -40
  11. package/src/adapter/entry-points/console/ui/vite.config.ts +1 -1
  12. package/src/adapter/entry-points/console/ui-dist/assets/index-2c5E3tdu.js +81 -0
  13. package/src/adapter/entry-points/console/ui-dist/assets/index-BaGbcbYI.css +2 -0
  14. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  15. package/src/adapter/repositories/GoogleSpreadsheetRepository.integration.test.ts +189 -55
  16. package/src/adapter/repositories/GoogleSpreadsheetRepository.test.ts +63 -5
  17. package/src/adapter/repositories/GoogleSpreadsheetRepository.ts +17 -2
  18. package/src/adapter/repositories/LocalStorageRepository.integration.test.ts +1 -1
  19. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +47 -35
  20. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +19 -16
  21. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts +1 -0
  22. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts.map +1 -1
  23. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +3 -0
  24. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
  25. package/bin/adapter/entry-points/console/ui-dist/assets/index-BTtIpfv_.css +0 -1
  26. package/bin/adapter/entry-points/console/ui-dist/assets/index-CMK3yyfo.js +0 -103
  27. package/src/adapter/entry-points/console/ui-dist/assets/index-BTtIpfv_.css +0 -1
  28. package/src/adapter/entry-points/console/ui-dist/assets/index-CMK3yyfo.js +0 -103
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-input:#e5e5e5;--color-ring:#0a0a0a;--color-background:#fff;--color-foreground:#0a0a0a;--color-primary:#171717;--color-primary-foreground:#fafafa;--color-secondary:#f5f5f5;--color-secondary-foreground:#171717;--color-accent:#f5f5f5;--color-accent-foreground:#171717}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.relative{position:relative}.static{position:static}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-input{border-color:var(--color-input)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--color-background)}.bg-primary{background-color:var(--color-primary)}.bg-secondary{background-color:var(--color-secondary)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-foreground{color:var(--color-foreground)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.lowercase{text-transform:lowercase}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-primary\/90:hover{background-color:#171717e6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--color-primary) 90%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--color-secondary) 80%, transparent)}}.hover\:text-accent-foreground:hover{color:var(--color-accent-foreground)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--color-ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}}html{font-size:clamp(15px,2.1vw,26px)}body{color:#e6edf3;background-color:#0d1117;margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.console-app{flex-direction:column;width:100%;min-height:100dvh;display:flex}.console-tabbar{-webkit-overflow-scrolling:touch;background:#161b22;border-bottom:2px solid #30363d;flex-wrap:nowrap;align-items:stretch;gap:0;min-height:42px;padding:0 8px;display:flex;overflow-x:auto}.console-tab{color:#8b949e;white-space:nowrap;cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;align-items:center;gap:6px;margin-bottom:-2px;padding:10px 14px;font-size:.8125rem;font-weight:500;line-height:1.2;text-decoration:none;display:inline-flex}.console-tab:hover{color:#e6edf3}.console-tab[data-active=true]{color:#e6edf3;border-bottom-color:#2f81f7;font-weight:700}.console-tab-badge{text-align:center;color:#e6edf3;background:#484f58;border-radius:20px;min-width:20px;padding:1px 7px;font-size:.6875rem;font-weight:700;line-height:1.5}.console-tab-badge[data-zero=true]{color:#8b949e;background:#30363d}.console-tab-pjname{color:#8b949e;align-self:center;margin-left:auto;padding:0 8px;font-size:.7188rem}.console-tab-geninfo{color:#8b949e;align-self:center;padding:0 4px;font-size:.6875rem}.console-list{margin:0;padding:12px 18px 18px;list-style:none}.console-list-group{list-style:none}.console-item-row .console-item-icon{flex:none;margin-top:3px}.console-group-header{background:#0b0f14;border-bottom:1px solid #21262d;justify-content:space-between;align-items:center;padding:6px 12px;display:flex}.console-storytag{align-items:center;gap:8px;font-size:.8125rem;font-weight:700;display:inline-flex}.console-story-dot{border-radius:999px;width:10px;height:10px;display:inline-block}.console-group-count{color:#8b949e;font-size:.75rem}.console-item-row{color:#e6edf3;text-align:left;cursor:pointer;background:#161b22;border:1px solid #30363d;border-radius:8px;align-items:flex-start;gap:14px;width:100%;margin-bottom:10px;padding:12px 16px;display:flex}.console-item-row:hover{background:#1a2029;border-color:#484f58}.console-item-row[data-active=true]{background:#1a2029;border-color:#4493f8}.console-item-meta{flex:1;min-width:0}.console-item-title{font-size:.9063rem;font-weight:600;display:block}.console-item-sub{color:#8b949e;margin-top:3px;font-size:.7813rem;display:block}.console-item-pill{color:#8b949e;border:1px solid #30363d;border-radius:20px;margin-right:6px;padding:1px 8px;font-size:.6875rem;display:inline-block}.console-item-createdat{color:#8b949e;cursor:help}.console-item-fields{color:#adbac7;flex-wrap:wrap;gap:4px 10px;margin-top:4px;font-size:.7188rem;display:flex}.console-item-field{word-break:break-all;align-items:baseline;gap:4px;min-width:0;display:inline-flex}.console-item-field-label{color:#6e7681;text-transform:uppercase;letter-spacing:.03em;font-size:.625rem}.console-list-message{color:#8b949e;padding:16px;font-size:.875rem}.console-list-empty{text-align:center;color:#8b949e;padding:40px;font-size:.875rem}.console-list-error,.console-comment-error,.console-files-error,.console-commits-error,.console-detail-body-error{color:#f85149}.console-detail{flex-direction:column;flex:1 0 auto;gap:12px;padding:16px 16px 0;display:flex}.console-detail-title{align-items:center;gap:8px;margin:0;font-size:1.25rem;display:flex}.console-detail-title-text{flex:1}.console-detail-number{color:#8b949e;font-weight:400}.console-detail-closed-label{color:#a371f7;font-size:.8125rem}.console-detail-subbar{align-items:center;gap:12px;font-size:.8125rem;display:flex}.console-detail-link{color:#4493f8}.console-detail-repo{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-copy-url-button{color:#c9d1d9;border:1px solid #30363d;border-radius:6px;height:24px;padding:0 8px;font-size:.75rem}.console-copy-url-button:hover{color:#e6edf3;background-color:#21262d}.console-detail-pill,.console-label-chip,.console-detail-status-chip{border:1px solid #30363d;border-radius:999px;padding:2px 8px;font-size:.75rem;display:inline-block}.console-detail-pr-status-row{flex-wrap:wrap;align-items:center;gap:6px;margin-top:-4px;display:flex}.console-detail-pr-status{flex-wrap:wrap;align-items:center;gap:6px;display:inline-flex}.console-detail-ci-missing{opacity:.85;font-weight:400}.console-detail-mergeable-chip{font-weight:600}.console-detail-mergeable-chip-unknown{opacity:.7;font-weight:400}.console-detail-labels{flex-wrap:wrap;gap:6px;display:flex}.console-detail-createdat{color:#8b949e;font-size:.75rem}.console-panel{border:1px solid #30363d;border-radius:8px;overflow:hidden}.console-panel-header{background:#161b22;justify-content:space-between;align-items:center;display:flex}.console-panel-toggle{color:#e6edf3;text-align:left;cursor:pointer;background:0 0;border:none;flex:1;align-items:center;gap:8px;padding:6px 12px;font-size:.875rem;font-weight:600;display:flex}.console-panel-toggle:hover{background:#1a2029}.console-panel-action{padding:6px 12px}.console-panel-body{padding:12px}.console-markdown{word-break:break-word;font-size:.875rem;line-height:1.5}.console-markdown ul{margin:.5em 0;padding-left:1.5em;list-style:outside}.console-markdown ol{margin:.5em 0;padding-left:1.5em;list-style:decimal}.console-markdown li{margin:.25em 0}.console-markdown table{border-collapse:collapse;margin:.5em 0}.console-markdown th,.console-markdown td{border:1px solid #30363d;padding:4px 8px}.console-markdown th{background:#161b22}.console-markdown h1,.console-markdown h2,.console-markdown h3,.console-markdown h4,.console-markdown h5,.console-markdown h6{margin:1em 0 .5em;font-weight:700;line-height:1.25}.console-markdown h1{font-size:1.6em}.console-markdown h2{font-size:1.4em}.console-markdown h3{font-size:1.2em}.console-markdown h4{font-size:1.05em}.console-markdown h5{font-size:.95em}.console-markdown h6{color:#8b949e;font-size:.9em}.console-markdown pre{background:#161b22;border:1px solid #30363d;border-radius:6px;margin:.5em 0;padding:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.8125rem;line-height:1.45;overflow-x:auto}.console-markdown code{background:#6e768166;border-radius:4px;padding:.15em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}.console-markdown pre code{font-size:inherit;background:0 0;border-radius:0;padding:0}.console-markdown blockquote{color:#8b949e;border-left:3px solid #30363d;margin:.5em 0;padding:0 1em}.console-markdown a{color:#4493f8;text-decoration:none}.console-markdown a:hover{text-decoration:underline}.console-markdown-reference-host{display:inline}.console-markdown-reference{vertical-align:baseline;align-items:center;gap:.25em;display:inline-flex}.console-markdown-reference .console-item-icon{flex-shrink:0}.console-markdown-reference-title{text-decoration:none}.console-markdown-reference:hover .console-markdown-reference-title{text-decoration:underline}.console-markdown hr{border:none;border-top:1px solid #30363d;margin:1em 0}.console-mermaid-error{color:#f85149;font-size:.8125rem}.console-comment{background:#161b22;border:1px solid #30363d;border-radius:8px;margin-bottom:10px;padding:12px 16px}.console-comment-header{color:#8b949e;gap:8px;font-size:.75rem;display:flex}.console-comment-show-all{color:#e6edf3;cursor:pointer;background:#21262d;border:1px solid #30363d;border-radius:6px;margin-bottom:10px;padding:6px 12px;font-size:.75rem;font-weight:600}.console-comment-show-all:hover{background-color:#262c34;border-color:#484f58}.console-comment-author{color:#e6edf3;font-weight:600}.console-files,.console-commits{margin:0;padding:0;list-style:none}.console-commit{align-items:center;gap:8px;padding:4px 0;font-size:.8125rem;display:flex}.console-file{flex-direction:column;font-size:.8125rem;display:flex}.console-file-tree,.console-file-tree-children{margin:0;padding:0;list-style:none}.console-file-tree-dir{flex-direction:column;display:flex}.console-file-tree-dir-name{color:#8b949e;align-items:center;gap:6px;padding:4px 0;font-weight:600;display:flex}.console-file-tree-dir-icon{flex:none}.console-file-badge{text-align:center;border:1px solid;border-radius:4px;width:18px;font-size:.6875rem}.console-file-path,.console-commit-message{flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-file-add,.console-pr-add{color:#3fb950}.console-file-del,.console-pr-del{color:#f85149}.console-commit-sha{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-actionbar{z-index:100;padding:10px 16px;padding-bottom:calc(10px + env(safe-area-inset-bottom));background:#161b22;border-top:2px solid #30363d;margin-top:auto;position:sticky;bottom:0}.console-operation-bar{flex-direction:column;gap:8px;width:100%;display:flex}.console-op-group{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.console-op-group-review{gap:10px}.console-op-group-stories{max-height:33vh;overflow-y:auto}.console-op-button{color:#e6edf3;white-space:nowrap;cursor:pointer;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:9px 16px;font-size:.8125rem;font-weight:600}.console-op-button:hover{border-color:#484f58}.console-op-button-approve{color:#fff;background:#238636;border-color:#2ea043}.console-op-button-reject{color:#ffd166;background:#7d5000;border-color:#a06800}.console-op-button-wrong{color:#f85149;background:#3a1518;border-color:#f85149}.console-op-button-unneeded{color:#aab0b8;background:#2a2d31;border-color:#6e7681}.console-op-button-snooze{color:#79c0ff;background:#1c2b4a;border-color:#4493f8}.console-pr-section{border:1px solid #30363d;border-radius:8px;flex-direction:column;gap:10px;padding:12px;display:flex}.console-pr-statbar{color:#8b949e;gap:12px;font-size:.75rem;display:flex}.console-detail-screen{flex-direction:column;flex:1 0 auto;display:flex}.console-panel-open-link{color:#4493f8;font-size:.8125rem;font-weight:400}.console-composer{margin-top:4px}.console-composer-toggle{color:#8b949e;cursor:pointer;background:0 0;border:none;padding:2px 0;font-size:.7813rem}.console-composer-toggle:hover{color:#e6edf3}.console-composer-posted{flex-direction:column;gap:8px;margin-top:8px;display:flex}.console-composer-form{margin-top:8px}.console-composer-input{box-sizing:border-box;color:#e6edf3;width:100%;font:inherit;resize:vertical;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:.875rem}.console-composer-row{justify-content:flex-end;align-items:center;gap:8px;margin-top:6px;display:flex}.console-composer-submit{color:#fff;cursor:pointer;background:#238636;border:1px solid #2ea043;border-radius:6px;padding:7px 16px;font-size:.8125rem;font-weight:600}.console-composer-submit:disabled{opacity:.6;cursor:default}.console-composer-status{color:#8b949e;font-size:.75rem}.console-composer-error{color:#f85149}.console-undo-toast{z-index:9998;white-space:nowrap;border-radius:10px;align-items:center;gap:12px;max-width:92vw;padding:12px 18px;font-size:.8438rem;display:flex;position:fixed;top:12px;left:50%;transform:translate(-50%);box-shadow:0 4px 20px #0009}.console-undo-toast-green{color:#3fb950;background:#0d3320;border:1px solid #2ea043}.console-undo-toast-amber{color:#ffd166;background:#3a2800;border:1px solid #a06800}.console-undo-toast-red{color:#f85149;background:#3a1518;border:1px solid #da3633}.console-undo-toast-gray{color:#aab0b8;background:#2a2d31;border:1px solid #6e7681}.console-undo-toast-blue{color:#79c0ff;background:#0d1f40;border:1px solid #4493f8}.console-undo-toast-error{color:#f85149;background:#3a1518;border:1px solid #f85149}.console-undo-toast-message{text-overflow:ellipsis;flex:1;min-width:0;overflow:hidden}.console-undo-toast-undo{cursor:pointer;color:inherit;background:#ffffff1f;border:none;border-radius:6px;flex:none;padding:4px 12px;font-size:.8125rem;font-weight:700}.console-undo-toast-undo:hover{background:#ffffff38}.console-undo-toast-countdown{opacity:.7;flex:none;font-size:.6875rem}.console-undo-toast-bar{opacity:.5;background:currentColor;border-radius:0 0 10px 10px;height:3px;transition:width .1s linear;position:absolute;bottom:0;left:0}.console-file-row{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:none;align-items:center;gap:8px;padding:4px 0;font-size:.8125rem;display:flex}.console-file-row:hover{background:#1a2029}.console-file-caret{color:#8b949e;flex:none;width:1em}.console-file-diff{border-collapse:collapse;width:100%;margin:4px 0 8px;font:.8125rem/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.console-file-diff td{white-space:pre-wrap;word-break:break-word;vertical-align:top;padding:0 10px}.console-diff-ln{text-align:right;color:#6e7681;-webkit-user-select:none;user-select:none;white-space:nowrap;border-right:1px solid #30363d;width:1%;padding:0 8px}.console-file-diff td.console-diff-ln{white-space:nowrap;word-break:normal;overflow-wrap:normal}.console-diff-add td{background:#2ea04326}.console-diff-add .console-diff-code{color:#aff5b4}.console-diff-del td{background:#f8514926}.console-diff-del .console-diff-code{color:#ffdcd7}.console-diff-hunk td{color:#79c0ff;background:#161b22}.console-diff-ctx .console-diff-code{color:#c9d1d9}.console-file-diff-empty{color:#8b949e;padding:8px 14px;font-size:.75rem}.console-diff-comment-cell{text-align:center;-webkit-user-select:none;user-select:none;width:1%;padding:0 2px}.console-diff-comment-button{color:#6e7681;cursor:pointer;opacity:.6;background:0 0;border:1px solid #30363d;border-radius:4px;width:18px;height:18px;padding:0;font-size:.875rem;line-height:16px}.console-diff-row:hover .console-diff-comment-button,.console-diff-comment-button:focus-visible,.console-diff-comment-button[aria-expanded=true]{opacity:1;color:#fff;background:#1f6feb;border-color:#2f81f7}.console-diff-composer-row td{background:#0d1117;padding:6px 10px}.console-diff-composer{background:#161b22;border:1px solid #30363d;border-radius:6px;flex-direction:column;gap:6px;padding:8px;display:flex}.console-diff-composer-anchor{color:#8b949e;font-size:.6875rem}.console-diff-composer-input{resize:vertical;color:#c9d1d9;width:100%;font:inherit;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:6px 8px}.console-diff-composer-controls{align-items:center;gap:8px;display:flex}.console-diff-composer-submit{color:#fff;cursor:pointer;background:#238636;border:1px solid #238636;border-radius:6px;padding:4px 12px}.console-diff-composer-submit:disabled{opacity:.6;cursor:default}.console-diff-composer-cancel{color:#c9d1d9;cursor:pointer;background:0 0;border:1px solid #30363d;border-radius:6px;padding:4px 12px}.console-diff-composer-status{color:#8b949e;font-size:.75rem}.console-diff-composer-error{color:#ff7b72}.console-diff-composer-posted{color:#3fb950;margin:0;font-size:.75rem}.console-pr-header{flex-wrap:wrap;align-items:center;gap:10px;padding-top:4px;display:flex}.console-pr-section-title{color:#e6edf3;font-size:.9375rem;font-weight:600}.console-pr-section-state{color:#8b949e;border:1px solid #6e7681;border-radius:999px;padding:1px 8px;font-size:.6875rem}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-CMK3yyfo.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BTtIpfv_.css">
7
+ <script type="module" crossorigin src="/assets/index-2c5E3tdu.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BaGbcbYI.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1,4 +1,5 @@
1
1
  import dotenv from 'dotenv';
2
+ import { google } from 'googleapis';
2
3
  import { GoogleSpreadsheetRepository } from './GoogleSpreadsheetRepository';
3
4
  import { LocalStorageRepository } from './LocalStorageRepository';
4
5
 
@@ -6,6 +7,24 @@ dotenv.config();
6
7
 
7
8
  const GOOGLE_SERVICE_ACCOUNT_KEY = process.env.GOOGLE_SERVICE_ACCOUNT_KEY;
8
9
 
10
+ const integrationTestIsRequired =
11
+ process.env.GOOGLE_SHEETS_INTEGRATION_REQUIRED === 'true';
12
+
13
+ const credentialIsMissingWhileRequired =
14
+ integrationTestIsRequired && !GOOGLE_SERVICE_ACCOUNT_KEY;
15
+
16
+ describe('GoogleSpreadsheetRepository integration test credential', () => {
17
+ test('is available whenever these integration tests are required to run', () => {
18
+ expect(
19
+ credentialIsMissingWhileRequired
20
+ ? 'GOOGLE_SERVICE_ACCOUNT_KEY is absent while GOOGLE_SHEETS_INTEGRATION_REQUIRED is true, so the Google Sheets integration tests would silently skip and prove nothing'
21
+ : 'the Google Sheets integration tests either hold their credential or are not required in this context',
22
+ ).toBe(
23
+ 'the Google Sheets integration tests either hold their credential or are not required in this context',
24
+ );
25
+ });
26
+ });
27
+
9
28
  const describeWhenCredentials = GOOGLE_SERVICE_ACCOUNT_KEY
10
29
  ? describe
11
30
  : describe.skip;
@@ -15,17 +34,101 @@ describeWhenCredentials('GoogleSpreadsheetRepository integration tests', () => {
15
34
  jest.retryTimes(3, { logErrorsBeforeRetry: true });
16
35
 
17
36
  const localStorageRepository = new LocalStorageRepository();
18
- const spreadsheetUrl =
19
- 'https://docs.google.com/spreadsheets/d/1N_3y0y46v5tHbra5YSm6PldflcsF1bkfeWDdQ3MRuXM/edit?gid=0#gid=0';
37
+ const spreadsheetId = '1N_3y0y46v5tHbra5YSm6PldflcsF1bkfeWDdQ3MRuXM';
38
+ const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit?gid=0#gid=0`;
20
39
  const repository = new GoogleSpreadsheetRepository(
21
40
  localStorageRepository,
22
41
  GOOGLE_SERVICE_ACCOUNT_KEY,
23
42
  );
24
43
 
44
+ const scratchSheetNamePrefix = 'IntegrationScratch_';
45
+ const scratchSheetName = `${scratchSheetNamePrefix}${Date.now()}_${process.pid}`;
46
+ const abandonedScratchSheetAgeMilliseconds = 2 * 60 * 60 * 1000;
47
+ const rawSheetsClientKeyFile =
48
+ './tmp/integration-test-service-account-key.json';
49
+
50
+ const createRawSheetsClient = () => {
51
+ if (!GOOGLE_SERVICE_ACCOUNT_KEY) {
52
+ throw new Error(
53
+ 'GOOGLE_SERVICE_ACCOUNT_KEY is required to run the Google Sheets integration tests',
54
+ );
55
+ }
56
+ localStorageRepository.write(
57
+ rawSheetsClientKeyFile,
58
+ GOOGLE_SERVICE_ACCOUNT_KEY,
59
+ );
60
+ const auth = new google.auth.GoogleAuth({
61
+ keyFile: rawSheetsClientKeyFile,
62
+ scopes: ['https://www.googleapis.com/auth/spreadsheets'],
63
+ });
64
+ return google.sheets({ version: 'v4', auth });
65
+ };
66
+
67
+ const listSheetTitles = async (): Promise<string[]> => {
68
+ const response = await createRawSheetsClient().spreadsheets.get({
69
+ spreadsheetId,
70
+ });
71
+ return (response.data.sheets ?? [])
72
+ .map((sheet) => sheet.properties?.title)
73
+ .filter((title): title is string => typeof title === 'string');
74
+ };
75
+
76
+ const deleteSheetsByTitle = async (titles: string[]): Promise<void> => {
77
+ if (titles.length === 0) {
78
+ return;
79
+ }
80
+ const sheetsClient = createRawSheetsClient();
81
+ const response = await sheetsClient.spreadsheets.get({ spreadsheetId });
82
+ const sheetIds = (response.data.sheets ?? [])
83
+ .filter((sheet) => {
84
+ const title = sheet.properties?.title;
85
+ return typeof title === 'string' && titles.includes(title);
86
+ })
87
+ .map((sheet) => sheet.properties?.sheetId)
88
+ .filter((sheetId): sheetId is number => typeof sheetId === 'number');
89
+ if (sheetIds.length === 0) {
90
+ return;
91
+ }
92
+ await sheetsClient.spreadsheets.batchUpdate({
93
+ spreadsheetId,
94
+ requestBody: {
95
+ requests: sheetIds.map((sheetId) => ({ deleteSheet: { sheetId } })),
96
+ },
97
+ });
98
+ };
99
+
100
+ const findAbandonedScratchSheetTitles = async (): Promise<string[]> => {
101
+ const titles = await listSheetTitles();
102
+ return titles
103
+ .filter((title) => title.startsWith(scratchSheetNamePrefix))
104
+ .filter((title) => title !== scratchSheetName)
105
+ .filter((title) => {
106
+ const createdAtMilliseconds = Number(
107
+ title.slice(scratchSheetNamePrefix.length).split('_')[0],
108
+ );
109
+ return (
110
+ Number.isFinite(createdAtMilliseconds) &&
111
+ Date.now() - createdAtMilliseconds >
112
+ abandonedScratchSheetAgeMilliseconds
113
+ );
114
+ });
115
+ };
116
+
25
117
  beforeEach(async () => {
26
118
  await new Promise((resolve) => setTimeout(resolve, 2000));
27
119
  });
28
120
 
121
+ afterAll(async () => {
122
+ const abandonedTitles = await findAbandonedScratchSheetTitles();
123
+ await deleteSheetsByTitle([scratchSheetName, ...abandonedTitles]);
124
+ });
125
+
126
+ describe('getSpreadsheetId', () => {
127
+ test('extracts the spreadsheet id from the spreadsheet url', () => {
128
+ expect(repository.getSpreadsheetId(spreadsheetUrl)).toBe(spreadsheetId);
129
+ });
130
+ });
131
+
29
132
  describe('getSheet', () => {
30
133
  const testCases: [string, string[][] | null][] = [
31
134
  ['SheetUndefined', null],
@@ -56,65 +159,96 @@ describeWhenCredentials('GoogleSpreadsheetRepository integration tests', () => {
56
159
  });
57
160
  });
58
161
 
59
- describe('updateCell', () => {
60
- const testCases: [string, number, number, string][] = [
61
- ['Sheet1', 0, 0, 'First Value'],
62
- ['Sheet1', 0, 0, 'Updated Value'],
63
- ['Sheet1', 1, 1, '123'],
64
- ['Sheet1', 2, 2, 'Test'],
65
- ];
162
+ describe('createNewSheetIfNotExists', () => {
163
+ test('creates the sheet and stays idempotent on a second call', async () => {
164
+ await deleteSheetsByTitle([scratchSheetName]);
165
+ expect(await listSheetTitles()).not.toContain(scratchSheetName);
66
166
 
67
- test.each(testCases)(
68
- 'updates cell in sheet %s at row %d col %d with value %s',
69
- async (sheetName: string, row: number, col: number, value: string) => {
70
- await repository.updateCell(spreadsheetUrl, sheetName, row, col, value);
71
- const result = await repository.getSheet(spreadsheetUrl, sheetName);
72
- if (!result) {
73
- throw new Error('Sheet not found');
74
- }
75
- expect(result[row][col]).toBe(value);
76
- },
77
- );
167
+ await repository.createNewSheetIfNotExists(
168
+ spreadsheetUrl,
169
+ scratchSheetName,
170
+ );
171
+ expect(await listSheetTitles()).toContain(scratchSheetName);
172
+ expect(
173
+ await repository.getSheet(spreadsheetUrl, scratchSheetName),
174
+ ).toBeNull();
175
+
176
+ await repository.createNewSheetIfNotExists(
177
+ spreadsheetUrl,
178
+ scratchSheetName,
179
+ );
180
+ const titlesAfterSecondCall = await listSheetTitles();
181
+ expect(
182
+ titlesAfterSecondCall.filter((title) => title === scratchSheetName)
183
+ .length,
184
+ ).toBe(1);
185
+ });
78
186
  });
79
187
 
80
188
  describe('appendSheetValues', () => {
81
- const testCases: [string[][]][] = [
82
- [[['Single Row']]],
83
- [[['Multiple', 'Columns']]],
84
- [
85
- [
86
- ['Row1Col1', 'Row1Col2'],
87
- ['Row2Col1', 'Row2Col2'],
88
- ],
89
- ],
90
- ];
189
+ test('appends rows to a sheet it creates and keeps the earlier rows', async () => {
190
+ await deleteSheetsByTitle([scratchSheetName]);
191
+
192
+ await repository.appendSheetValues(spreadsheetUrl, scratchSheetName, [
193
+ ['firstRowFirstColumn', 'firstRowSecondColumn'],
194
+ ]);
195
+ expect(
196
+ await repository.getSheet(spreadsheetUrl, scratchSheetName),
197
+ ).toEqual([['firstRowFirstColumn', 'firstRowSecondColumn']]);
198
+
199
+ await repository.appendSheetValues(spreadsheetUrl, scratchSheetName, [
200
+ ['secondRowFirstColumn', 'secondRowSecondColumn'],
201
+ ['thirdRowFirstColumn', 'thirdRowSecondColumn'],
202
+ ]);
203
+ expect(
204
+ await repository.getSheet(spreadsheetUrl, scratchSheetName),
205
+ ).toEqual([
206
+ ['firstRowFirstColumn', 'firstRowSecondColumn'],
207
+ ['secondRowFirstColumn', 'secondRowSecondColumn'],
208
+ ['thirdRowFirstColumn', 'thirdRowSecondColumn'],
209
+ ]);
210
+ });
211
+ });
91
212
 
92
- test.each(testCases)(
93
- 'appends values %j to sheet',
94
- async (values: string[][]) => {
95
- const sheetName = 'AppendTest';
96
- const initialSheet = await repository.getSheet(
97
- spreadsheetUrl,
98
- sheetName,
99
- );
100
- const initialLength = initialSheet ? initialSheet.length : 0;
213
+ describe('updateCell', () => {
214
+ test('creates the sheet when missing, writes a cell, and overwrites it', async () => {
215
+ await deleteSheetsByTitle([scratchSheetName]);
101
216
 
102
- await repository.appendSheetValues(spreadsheetUrl, sheetName, values);
217
+ await repository.updateCell(
218
+ spreadsheetUrl,
219
+ scratchSheetName,
220
+ 0,
221
+ 0,
222
+ 'firstValue',
223
+ );
224
+ expect(
225
+ await repository.getSheet(spreadsheetUrl, scratchSheetName),
226
+ ).toEqual([['firstValue']]);
103
227
 
104
- const updatedSheet = await repository.getSheet(
105
- spreadsheetUrl,
106
- sheetName,
107
- );
108
- expect(updatedSheet).not.toBeNull();
109
- if (!updatedSheet) {
110
- throw new Error('Sheet not found');
111
- }
112
- expect(updatedSheet.length).toBe(initialLength + values.length);
113
-
114
- for (let i = 0; i < values.length; i++) {
115
- expect(updatedSheet[initialLength + i]).toEqual(values[i]);
116
- }
117
- },
118
- );
228
+ await repository.updateCell(
229
+ spreadsheetUrl,
230
+ scratchSheetName,
231
+ 0,
232
+ 0,
233
+ 'updatedValue',
234
+ );
235
+ expect(
236
+ await repository.getSheet(spreadsheetUrl, scratchSheetName),
237
+ ).toEqual([['updatedValue']]);
238
+
239
+ await repository.updateCell(
240
+ spreadsheetUrl,
241
+ scratchSheetName,
242
+ 2,
243
+ 2,
244
+ 'thirdRowThirdColumn',
245
+ );
246
+ const sheet = await repository.getSheet(spreadsheetUrl, scratchSheetName);
247
+ if (!sheet) {
248
+ throw new Error('Sheet not found');
249
+ }
250
+ expect(sheet[0][0]).toBe('updatedValue');
251
+ expect(sheet[2][2]).toBe('thirdRowThirdColumn');
252
+ });
119
253
  });
120
254
  });
@@ -23,11 +23,43 @@ describe('GoogleSpreadsheetRepository', () => {
23
23
  const spreadsheetUrl =
24
24
  'https://docs.google.com/spreadsheets/d/1N_3y0y46v5tHbra5YSm6PldflcsF1bkfeWDdQ3MRuXM/edit?gid=0#gid=0';
25
25
 
26
- const mockSpreadsheetsGet = jest.fn<() => Promise<SpreadsheetGetResponse>>();
27
- const mockSpreadsheetsValuesGet = jest.fn<() => Promise<ValuesGetResponse>>();
28
- const mockSpreadsheetsValuesUpdate = jest.fn<() => Promise<SimpleResponse>>();
29
- const mockSpreadsheetsValuesAppend = jest.fn<() => Promise<SimpleResponse>>();
30
- const mockSpreadsheetsBatchUpdate = jest.fn<() => Promise<SimpleResponse>>();
26
+ const mockSpreadsheetsGet =
27
+ jest.fn<
28
+ (params: { spreadsheetId: string }) => Promise<SpreadsheetGetResponse>
29
+ >();
30
+ const mockSpreadsheetsValuesGet =
31
+ jest.fn<
32
+ (params: {
33
+ spreadsheetId: string;
34
+ range: string;
35
+ }) => Promise<ValuesGetResponse>
36
+ >();
37
+ const mockSpreadsheetsValuesUpdate =
38
+ jest.fn<
39
+ (params: {
40
+ spreadsheetId: string;
41
+ range: string;
42
+ valueInputOption: string;
43
+ requestBody: { values: string[][] };
44
+ }) => Promise<SimpleResponse>
45
+ >();
46
+ const mockSpreadsheetsValuesAppend =
47
+ jest.fn<
48
+ (params: {
49
+ spreadsheetId: string;
50
+ range: string;
51
+ valueInputOption: string;
52
+ requestBody: { values: string[][] };
53
+ }) => Promise<SimpleResponse>
54
+ >();
55
+ const mockSpreadsheetsBatchUpdate = jest.fn<
56
+ (params: {
57
+ spreadsheetId: string;
58
+ requestBody: {
59
+ requests: Array<{ addSheet?: { properties?: { title?: string } } }>;
60
+ };
61
+ }) => Promise<SimpleResponse>
62
+ >();
31
63
 
32
64
  const mockSheetsClient = {
33
65
  spreadsheets: {
@@ -289,6 +321,32 @@ describe('GoogleSpreadsheetRepository', () => {
289
321
  );
290
322
  });
291
323
 
324
+ test('does not recreate an existing sheet that holds no values', async () => {
325
+ mockSpreadsheetsGet.mockResolvedValue({
326
+ status: 200,
327
+ data: { sheets: [{ properties: { title: 'EmptyExistingSheet' } }] },
328
+ });
329
+ mockSpreadsheetsValuesGet.mockResolvedValue({
330
+ status: 200,
331
+ data: {},
332
+ });
333
+ mockSpreadsheetsValuesAppend.mockResolvedValue({
334
+ status: 200,
335
+ data: {},
336
+ });
337
+
338
+ await repository.appendSheetValues(spreadsheetUrl, 'EmptyExistingSheet', [
339
+ ['Row'],
340
+ ]);
341
+
342
+ expect(mockSpreadsheetsBatchUpdate).not.toHaveBeenCalled();
343
+ expect(mockSpreadsheetsValuesAppend).toHaveBeenCalledWith(
344
+ expect.objectContaining({
345
+ range: 'EmptyExistingSheet!A1:A',
346
+ }),
347
+ );
348
+ });
349
+
292
350
  test('creates new sheet via batchUpdate when sheet does not exist', async () => {
293
351
  mockSpreadsheetsGet.mockResolvedValue({
294
352
  status: 200,
@@ -155,14 +155,29 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
155
155
  );
156
156
  }
157
157
  };
158
+ private sheetExists = async (
159
+ spreadsheetUrl: string,
160
+ sheetName: string,
161
+ ): Promise<boolean> => {
162
+ const sheets = this.sheetsClient;
163
+ const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
164
+ const response = await sheets.spreadsheets.get({ spreadsheetId });
165
+ if (response.status !== 200) {
166
+ throw new Error(
167
+ `Failed to get sheet: ${response.status}. ${JSON.stringify(response.data)}`,
168
+ );
169
+ }
170
+ return (response.data.sheets ?? []).some(
171
+ (sheet) => sheet.properties?.title === sheetName,
172
+ );
173
+ };
158
174
  createNewSheetIfNotExists = async (
159
175
  spreadsheetUrl: string,
160
176
  sheetName: string,
161
177
  ): Promise<void> => {
162
178
  const sheets = this.sheetsClient;
163
179
  const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
164
- const sheet = await this.getSheet(spreadsheetUrl, sheetName);
165
- if (sheet !== null) {
180
+ if (await this.sheetExists(spreadsheetUrl, sheetName)) {
166
181
  return;
167
182
  }
168
183
  const response = await sheets.spreadsheets.batchUpdate({
@@ -132,7 +132,7 @@ describe('LocalStorageRepository', () => {
132
132
  testCases.forEach(({ name, params }) => {
133
133
  test(name, () => {
134
134
  if (fs.existsSync(params[0])) {
135
- fs.rmdirSync(params[0], { recursive: true });
135
+ fs.rmSync(params[0], { recursive: true });
136
136
  }
137
137
  repository.mkdir(...params);
138
138
  expect(fs.existsSync(params[0])).toBe(true);
@@ -2,6 +2,9 @@ import {
2
2
  ComposeDashboardInput,
3
3
  ComposeDashboardUseCase,
4
4
  PROJECT_ROW_WIDTH_BUDGET,
5
+ SEVEN_DAY_UTILIZATION_COLUMN_START,
6
+ STATUS_DOT_DISPLAY_WIDTH,
7
+ TOKEN_UTILIZATION_WIDTH,
5
8
  formatMachineStatusLines,
6
9
  formatProjectHeaderLine,
7
10
  formatProjectRowLine,
@@ -11,7 +14,7 @@ import {
11
14
  roundHalfToEven,
12
15
  } from './ComposeDashboardUseCase';
13
16
  import { DashboardRow } from './GenerateDashboardRowUseCase';
14
- import { TokenStatus } from './GenerateTokenStatusUseCase';
17
+ import { TokenStatus, TokenStatusColor } from './GenerateTokenStatusUseCase';
15
18
 
16
19
  const codePointLength = (value: string): number => [...value].length;
17
20
 
@@ -487,56 +490,63 @@ describe('ComposeDashboardUseCase', () => {
487
490
  });
488
491
  });
489
492
 
490
- describe('formatSevenDayWindowAggregateLine', () => {
491
- const columnEnd = (line: string, field: string): number =>
492
- codePointLength(line.slice(0, line.indexOf(field) + field.length));
493
+ const inDisplayColumns = (tokenRowLine: string): string =>
494
+ ' '.repeat(STATUS_DOT_DISPLAY_WIDTH) + [...tokenRowLine].slice(1).join('');
495
+
496
+ const sevenDayUtilizationColumn = (line: string): number =>
497
+ [...line].lastIndexOf('%') + 1 - TOKEN_UTILIZATION_WIDTH;
493
498
 
494
- it('renders only the remaining percentage, without a count when every token is included', () => {
499
+ describe('formatSevenDayWindowAggregateLine', () => {
500
+ it('renders only the used percentage, with no label and no count when every token is included', () => {
495
501
  expect(
496
502
  formatSevenDayWindowAggregateLine({
497
503
  usedPercent: 63,
498
504
  includedTokenCount: 10,
499
505
  totalTokenCount: 10,
500
506
  }),
501
- ).toBe('7d 37%');
507
+ ).toBe(' '.repeat(21) + '63%');
502
508
  });
503
509
 
504
- it('aligns the remaining percentage under the seven day column of the token rows', () => {
505
- const tokenRow = formatTokenRowLine(
506
- tokenStatus({
507
- name: 'dev4',
508
- color: 'K',
509
- fiveHourUtilizationPercent: 0,
510
- fiveHourResetSeconds: 60,
511
- sevenDayUtilizationPercent: 100,
512
- sevenDayResetSeconds: 96060,
513
- }),
514
- );
510
+ it('places the used percentage in the same display column as the seven day utilization of every token row', () => {
515
511
  const aggregateLine = formatSevenDayWindowAggregateLine({
516
512
  usedPercent: 63.36,
517
513
  includedTokenCount: 11,
518
514
  totalTokenCount: 11,
519
515
  });
520
- expect(tokenRow).toBe('⚪dev4 0% 0d00h01 100% 1d02h41 0 0');
521
- expect(aggregateLine).toBe('7d 37%');
522
- expect(columnEnd(aggregateLine ?? '', '37%')).toBe(
523
- columnEnd(tokenRow, '100%'),
516
+ expect(sevenDayUtilizationColumn(aggregateLine ?? '')).toBe(
517
+ SEVEN_DAY_UTILIZATION_COLUMN_START,
524
518
  );
519
+ const colors: TokenStatusColor[] = ['G', 'Y', 'K'];
520
+ for (const color of colors) {
521
+ const tokenRow = formatTokenRowLine(
522
+ tokenStatus({
523
+ name: 'dev4',
524
+ color,
525
+ fiveHourUtilizationPercent: 0,
526
+ fiveHourResetSeconds: 60,
527
+ sevenDayUtilizationPercent: 100,
528
+ sevenDayResetSeconds: 96060,
529
+ }),
530
+ );
531
+ expect(sevenDayUtilizationColumn(inDisplayColumns(tokenRow))).toBe(
532
+ SEVEN_DAY_UTILIZATION_COLUMN_START,
533
+ );
534
+ }
525
535
  });
526
536
 
527
- it('keeps the alignment when the remaining percentage needs fewer digits', () => {
537
+ it('keeps the alignment when the used percentage needs fewer digits', () => {
528
538
  const wide = formatSevenDayWindowAggregateLine({
529
- usedPercent: 0,
539
+ usedPercent: 100,
530
540
  includedTokenCount: 1,
531
541
  totalTokenCount: 1,
532
542
  });
533
543
  const narrow = formatSevenDayWindowAggregateLine({
534
- usedPercent: 95,
544
+ usedPercent: 5,
535
545
  includedTokenCount: 1,
536
546
  totalTokenCount: 1,
537
547
  });
538
- expect(wide).toBe('7d 100%');
539
- expect(narrow).toBe('7d 5%');
548
+ expect(wide).toBe(' '.repeat(20) + '100%');
549
+ expect(narrow).toBe(' '.repeat(22) + '5%');
540
550
  expect(codePointLength(wide ?? '')).toBe(codePointLength(narrow ?? ''));
541
551
  });
542
552
 
@@ -547,24 +557,24 @@ describe('formatSevenDayWindowAggregateLine', () => {
547
557
  includedTokenCount: 9,
548
558
  totalTokenCount: 10,
549
559
  }),
550
- ).toBe('7d 37% (9)');
560
+ ).toBe(' '.repeat(21) + '63% (9)');
551
561
  });
552
562
 
553
- it('rounds the remaining percentage with half-to-even before rendering', () => {
563
+ it('rounds the used percentage with half-to-even before rendering', () => {
554
564
  expect(
555
565
  formatSevenDayWindowAggregateLine({
556
566
  usedPercent: 62.5,
557
567
  includedTokenCount: 2,
558
568
  totalTokenCount: 2,
559
569
  }),
560
- ).toBe('7d 38%');
570
+ ).toBe(' '.repeat(21) + '62%');
561
571
  expect(
562
572
  formatSevenDayWindowAggregateLine({
563
573
  usedPercent: 63.5,
564
574
  includedTokenCount: 2,
565
575
  totalTokenCount: 2,
566
576
  }),
567
- ).toBe('7d 36%');
577
+ ).toBe(' '.repeat(21) + '64%');
568
578
  });
569
579
 
570
580
  it('renders nothing when the aggregate is absent', () => {
@@ -573,11 +583,11 @@ describe('formatSevenDayWindowAggregateLine', () => {
573
583
 
574
584
  it('fits the width budget at the widest rendering', () => {
575
585
  const line = formatSevenDayWindowAggregateLine({
576
- usedPercent: 0,
586
+ usedPercent: 100,
577
587
  includedTokenCount: 999,
578
588
  totalTokenCount: 1000,
579
589
  });
580
- expect(line).toBe('7d 100% (999)');
590
+ expect(line).toBe(' '.repeat(20) + '100% (999)');
581
591
  expect(codePointLength(line ?? '')).toBeLessThanOrEqual(
582
592
  PROJECT_ROW_WIDTH_BUDGET,
583
593
  );
@@ -618,7 +628,7 @@ describe('ComposeDashboardUseCase seven day window aggregate line', () => {
618
628
  }),
619
629
  ),
620
630
  );
621
- const aggregateIndex = lines.indexOf('7d 37%');
631
+ const aggregateIndex = lines.indexOf(' '.repeat(21) + '63%');
622
632
  expect(aggregateIndex).toBeGreaterThan(-1);
623
633
  expect(lines[aggregateIndex - 1]).toBe(
624
634
  formatProjectRowLine({
@@ -629,11 +639,13 @@ describe('ComposeDashboardUseCase seven day window aggregate line', () => {
629
639
  expect(lines[aggregateIndex + 1]).toContain('alice');
630
640
  });
631
641
 
642
+ const AGGREGATE_LINE_SHAPE = /^ +\d+%( \(\d+\))?$/;
643
+
632
644
  it('keeps the blank separator line when the aggregate is absent', () => {
633
645
  const lines = unwrappedLines(
634
646
  new ComposeDashboardUseCase().run(aggregateInput(null)),
635
647
  );
636
- expect(lines.some((line) => line.startsWith('7d '))).toBe(false);
648
+ expect(lines.some((line) => AGGREGATE_LINE_SHAPE.test(line))).toBe(false);
637
649
  expect(lines).toContain('');
638
650
  });
639
651
 
@@ -645,7 +657,7 @@ describe('ComposeDashboardUseCase seven day window aggregate line', () => {
645
657
  tokens: [tokenStatus({ name: 'alice' })],
646
658
  }),
647
659
  );
648
- expect(lines.some((line) => line.startsWith('7d '))).toBe(false);
660
+ expect(lines.some((line) => AGGREGATE_LINE_SHAPE.test(line))).toBe(false);
649
661
  expect(lines).toContain('');
650
662
  });
651
663
  });