github-issue-tower-defence-management 1.179.0 → 1.180.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 (70) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/adapter/entry-points/console/consoleOperationApi.js +42 -1
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  6. package/bin/adapter/entry-points/console/webServer.js +6 -0
  7. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  8. package/bin/adapter/repositories/SystemDateRepository.js +10 -10
  9. package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +1 -1
  13. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
  14. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
  15. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
  16. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +1 -1
  17. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -1
  18. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +3 -3
  19. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
  20. package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -6
  21. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  22. package/bin/domain/usecases/IssueRejectionEvaluator.js +3 -1
  23. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  24. package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
  25. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  26. package/bin/domain/usecases/issueReactivationTriggerIsPending.js +2 -2
  27. package/bin/domain/usecases/issueReactivationTriggerIsPending.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +230 -0
  30. package/src/adapter/entry-points/console/consoleOperationApi.ts +48 -1
  31. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +36 -0
  32. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +25 -1
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.stories.tsx +30 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.test.tsx +111 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.tsx +91 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +21 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.test.ts +24 -2
  38. package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.ts +3 -1
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +60 -11
  40. package/src/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
  41. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  42. package/src/adapter/entry-points/console/webServer.ts +11 -0
  43. package/src/adapter/repositories/SystemDateRepository.test.ts +60 -0
  44. package/src/adapter/repositories/SystemDateRepository.ts +10 -10
  45. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +268 -2
  46. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +33 -4
  47. package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +1 -1
  48. package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +80 -0
  49. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
  50. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +6 -4
  51. package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +225 -0
  52. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +3 -3
  53. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +107 -0
  54. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -7
  55. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +83 -0
  56. package/src/domain/usecases/IssueRejectionEvaluator.ts +3 -1
  57. package/src/domain/usecases/StartPreparationUseCase.ts +5 -3
  58. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +33 -33
  59. package/src/domain/usecases/issueReactivationTriggerIsPending.ts +6 -4
  60. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -1
  61. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  62. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  63. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  64. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  65. package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -1
  66. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  67. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  68. package/types/domain/usecases/issueReactivationTriggerIsPending.d.ts.map +1 -1
  69. package/bin/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
  70. package/src/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
@@ -4,7 +4,7 @@
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-KCmqh781.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-I_2Yu8fM.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DeZcPNCr.css">
9
9
  </head>
10
10
  <body>
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { IssueAttachmentRepository } from '../../../domain/usecases/adapter-interfaces/IssueAttachmentRepository';
5
5
  import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
6
+ import { ProjectRepository } from '../../../domain/usecases/adapter-interfaces/ProjectRepository';
6
7
  import {
7
8
  CONSOLE_LIST_TAB_NAMES,
8
9
  buildConsoleDataResponse,
@@ -28,6 +29,7 @@ import {
28
29
  handleComment,
29
30
  handleCreateIssue,
30
31
  handleIntmux,
32
+ handleReorderStory,
31
33
  handleReview,
32
34
  handleReviewComment,
33
35
  handleTriage,
@@ -232,6 +234,7 @@ export type WebServerOptions = {
232
234
  resolveProject?: ConsoleProjectResolver | null;
233
235
  isPjcodeConfigured?: ConsolePjcodeValidator | null;
234
236
  issueAttachmentRepository?: IssueAttachmentRepository | null;
237
+ projectRepository?: Pick<ProjectRepository, 'updateStoryList'> | null;
235
238
  issueTitleStateCache?: IssueTitleStateCache | null;
236
239
  pullRequestStatusCache?: PullRequestStatusCache | null;
237
240
  };
@@ -529,6 +532,8 @@ const dispatchOperation = (
529
532
  return handleAttachmentUpload(context, body);
530
533
  case '/api/createissue':
531
534
  return handleCreateIssue(context, body);
535
+ case '/api/reorderstory':
536
+ return handleReorderStory(context, body);
532
537
  default:
533
538
  return null;
534
539
  }
@@ -551,12 +556,18 @@ const handleOperationApi = async (
551
556
  }
552
557
  const resolveIssueRepository =
553
558
  options.resolveIssueRepository ?? ((): IssueRepository => issueRepository);
559
+ const projectRepository = options.projectRepository ?? null;
554
560
  const context: ConsoleOperationContext = {
555
561
  resolveIssueRepository,
556
562
  resolveProject,
557
563
  isPjcodeConfigured,
558
564
  consoleDataOutputDir: options.consoleDataOutputDir,
559
565
  issueAttachmentRepository: options.issueAttachmentRepository ?? null,
566
+ updateStoryList:
567
+ projectRepository !== null
568
+ ? (project, stories) =>
569
+ projectRepository.updateStoryList(project, stories)
570
+ : null,
560
571
  };
561
572
  const dispatched = dispatchOperation(context, requestPath, body);
562
573
  if (dispatched === null) {
@@ -0,0 +1,60 @@
1
+ import { SystemDateRepository } from './SystemDateRepository';
2
+
3
+ describe('SystemDateRepository', () => {
4
+ const repo = new SystemDateRepository();
5
+
6
+ const jan15At09h05 = new Date(Date.UTC(2026, 0, 15, 9, 5, 0));
7
+ const jan15At10h30 = new Date(Date.UTC(2026, 0, 15, 10, 30, 0));
8
+ const jan16At08h00 = new Date(Date.UTC(2026, 0, 16, 8, 0, 0));
9
+
10
+ describe('formatDurationToHHMM', () => {
11
+ it('formats whole hours', () => {
12
+ expect(repo.formatDurationToHHMM(120)).toBe('02:00');
13
+ });
14
+
15
+ it('formats hours and minutes', () => {
16
+ expect(repo.formatDurationToHHMM(90)).toBe('01:30');
17
+ });
18
+
19
+ it('formats minutes only', () => {
20
+ expect(repo.formatDurationToHHMM(5)).toBe('00:05');
21
+ });
22
+ });
23
+
24
+ describe('formatDateWithDayOfWeek', () => {
25
+ it('formats a UTC date with day-of-week in YYYY/MM/DD (Day) form', () => {
26
+ expect(repo.formatDateWithDayOfWeek(jan15At09h05)).toBe(
27
+ '2026/01/15 (Thu)',
28
+ );
29
+ });
30
+ });
31
+
32
+ describe('formatDateTimeWithDayOfWeek', () => {
33
+ it('appends UTC HH:MM to the date-with-day-of-week', () => {
34
+ expect(repo.formatDateTimeWithDayOfWeek(jan15At09h05)).toBe(
35
+ '2026/01/15 (Thu) 09:05',
36
+ );
37
+ });
38
+
39
+ it('pads single-digit hour and minute', () => {
40
+ const date = new Date(Date.UTC(2026, 2, 3, 4, 7, 0));
41
+ expect(repo.formatDateTimeWithDayOfWeek(date)).toBe(
42
+ '2026/03/03 (Tue) 04:07',
43
+ );
44
+ });
45
+ });
46
+
47
+ describe('formatStartEnd', () => {
48
+ it('omits the date for same-day end', () => {
49
+ expect(repo.formatStartEnd(jan15At09h05, jan15At10h30)).toBe(
50
+ '2026/01/15 (Thu) 09:05 - 10:30',
51
+ );
52
+ });
53
+
54
+ it('includes full date-time for end on a different day', () => {
55
+ expect(repo.formatStartEnd(jan15At09h05, jan16At08h00)).toBe(
56
+ '2026/01/15 (Thu) 09:05 - 2026/01/16 (Fri) 08:00',
57
+ );
58
+ });
59
+ });
60
+ });
@@ -9,25 +9,25 @@ export class SystemDateRepository implements DateRepository {
9
9
  };
10
10
  formatDateTimeWithDayOfWeek = (date: Date): string => {
11
11
  const dateWithDayOfWeek = this.formatDateWithDayOfWeek(date);
12
- const hours = String(date.getHours()).padStart(2, '0');
13
- const minutes = String(date.getMinutes()).padStart(2, '0');
12
+ const hours = String(date.getUTCHours()).padStart(2, '0');
13
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
14
14
  return `${dateWithDayOfWeek} ${hours}:${minutes}`;
15
15
  };
16
16
 
17
17
  formatDateWithDayOfWeek = (date: Date): string => {
18
18
  const dayOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][
19
- date.getDay()
19
+ date.getUTCDay()
20
20
  ];
21
- const month = String(date.getMonth() + 1).padStart(2, '0');
22
- const day = String(date.getDate()).padStart(2, '0');
23
- return `${date.getFullYear()}/${month}/${day} (${dayOfWeek})`;
21
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
22
+ const day = String(date.getUTCDate()).padStart(2, '0');
23
+ return `${date.getUTCFullYear()}/${month}/${day} (${dayOfWeek})`;
24
24
  };
25
25
  formatStartEnd = (start: Date, end: Date): string => {
26
26
  const endDate =
27
- start.getFullYear() === end.getFullYear() &&
28
- start.getMonth() === end.getMonth() &&
29
- start.getDate() === end.getDate()
30
- ? `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
27
+ start.getUTCFullYear() === end.getUTCFullYear() &&
28
+ start.getUTCMonth() === end.getUTCMonth() &&
29
+ start.getUTCDate() === end.getUTCDate()
30
+ ? `${String(end.getUTCHours()).padStart(2, '0')}:${String(end.getUTCMinutes()).padStart(2, '0')}`
31
31
  : this.formatDateTimeWithDayOfWeek(end);
32
32
  return `${this.formatDateTimeWithDayOfWeek(start)} - ${endDate}`;
33
33
  };
@@ -394,6 +394,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
394
394
  dateRepository,
395
395
  } = createApiV3CheerioRestIssueRepository();
396
396
  const cachedProject = buildTestProject('cached-project');
397
+ const freshProject = buildTestProject('cached-project');
397
398
  dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
398
399
  localStorageCacheRepository.getSingle.mockResolvedValue({
399
400
  lastFetchedAt: '2026-07-07T00:30:00.000Z',
@@ -406,6 +407,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
406
407
  ),
407
408
  ],
408
409
  });
410
+ projectRepository.getProject.mockResolvedValue(freshProject);
409
411
  graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
410
412
  buildLightItem(
411
413
  'item-fresh',
@@ -427,8 +429,10 @@ describe('ApiV3CheerioRestIssueRepository', () => {
427
429
  const result = await repository.getAllIssues('cached-project');
428
430
 
429
431
  expect(result.cacheUsed).toBe(true);
430
- expect(result.project).toBe(cachedProject);
431
- expect(projectRepository.getProject).not.toHaveBeenCalled();
432
+ expect(result.project).toBe(freshProject);
433
+ expect(projectRepository.getProject).toHaveBeenCalledWith(
434
+ 'cached-project',
435
+ );
432
436
  const lightCall =
433
437
  graphqlProjectItemRepository.fetchProjectItemsLight.mock.calls[0];
434
438
  expect(lightCall[0]).toBe('cached-project');
@@ -463,6 +467,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
463
467
  repository,
464
468
  graphqlProjectItemRepository,
465
469
  localStorageCacheRepository,
470
+ projectRepository,
466
471
  dateRepository,
467
472
  } = createApiV3CheerioRestIssueRepository();
468
473
  dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
@@ -472,6 +477,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
472
477
  project: buildTestProject('cached-project'),
473
478
  issues: [],
474
479
  });
480
+ projectRepository.getProject.mockResolvedValue(
481
+ buildTestProject('cached-project'),
482
+ );
475
483
  graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
476
484
  buildLightItem(
477
485
  'wellBefore',
@@ -509,6 +517,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
509
517
  repository,
510
518
  graphqlProjectItemRepository,
511
519
  localStorageCacheRepository,
520
+ projectRepository,
512
521
  dateRepository,
513
522
  } = createApiV3CheerioRestIssueRepository();
514
523
  dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:30:00Z'));
@@ -518,6 +527,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
518
527
  project: buildTestProject('cached-project'),
519
528
  issues: [],
520
529
  });
530
+ projectRepository.getProject.mockResolvedValue(
531
+ buildTestProject('cached-project'),
532
+ );
521
533
  graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
522
534
  buildLightItem(
523
535
  'previousDay',
@@ -548,6 +560,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
548
560
  repository,
549
561
  graphqlProjectItemRepository,
550
562
  localStorageCacheRepository,
563
+ projectRepository,
551
564
  dateRepository,
552
565
  } = createApiV3CheerioRestIssueRepository();
553
566
  dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
@@ -562,6 +575,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
562
575
  ),
563
576
  ],
564
577
  });
578
+ projectRepository.getProject.mockResolvedValue(
579
+ buildTestProject('cached-project'),
580
+ );
565
581
  graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
566
582
  buildLightItem(
567
583
  'stale',
@@ -618,6 +634,252 @@ describe('ApiV3CheerioRestIssueRepository', () => {
618
634
  });
619
635
  });
620
636
 
637
+ describe('getAllIssues story option rename detection', () => {
638
+ const buildStoryOption = (
639
+ id: string,
640
+ name: string,
641
+ ): {
642
+ id: string;
643
+ name: string;
644
+ color: 'GRAY';
645
+ description: string;
646
+ } => ({ id, name, color: 'GRAY', description: '' });
647
+
648
+ const buildProjectWithStories = (
649
+ id: string,
650
+ stories: { id: string; name: string }[],
651
+ ): Project => ({
652
+ ...buildTestProject(id),
653
+ story: {
654
+ name: 'Story',
655
+ fieldId: 'story-field-id',
656
+ databaseId: 1,
657
+ stories: stories.map((s) => buildStoryOption(s.id, s.name)),
658
+ workflowManagementStory: { id: 'wms-id', name: 'workflow management' },
659
+ },
660
+ });
661
+
662
+ it('calls projectRepository.getProject on every incremental fetch cycle', async () => {
663
+ const {
664
+ repository,
665
+ graphqlProjectItemRepository,
666
+ localStorageCacheRepository,
667
+ projectRepository,
668
+ dateRepository,
669
+ } = createApiV3CheerioRestIssueRepository();
670
+ const cachedProject = buildTestProject('cached-project');
671
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
672
+ localStorageCacheRepository.getSingle.mockResolvedValue({
673
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
674
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
675
+ project: cachedProject,
676
+ issues: [],
677
+ });
678
+ projectRepository.getProject.mockResolvedValue(cachedProject);
679
+ graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
680
+ localStorageCacheRepository.setSingle.mockResolvedValue();
681
+
682
+ await repository.getAllIssues('cached-project');
683
+
684
+ expect(projectRepository.getProject).toHaveBeenCalledWith(
685
+ 'cached-project',
686
+ );
687
+ });
688
+
689
+ it('escalates to full fetch when a story option name changes between cache and fresh project', async () => {
690
+ const {
691
+ repository,
692
+ graphqlProjectItemRepository,
693
+ localStorageCacheRepository,
694
+ projectRepository,
695
+ dateRepository,
696
+ } = createApiV3CheerioRestIssueRepository();
697
+ const cachedProject = buildProjectWithStories('cached-project', [
698
+ { id: 'opt-1', name: 'Old Story Name' },
699
+ ]);
700
+ const freshProject = buildProjectWithStories('cached-project', [
701
+ { id: 'opt-1', name: 'New Story Name' },
702
+ ]);
703
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
704
+ localStorageCacheRepository.getSingle.mockResolvedValue({
705
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
706
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
707
+ project: cachedProject,
708
+ issues: [],
709
+ });
710
+ projectRepository.getProject.mockResolvedValue(freshProject);
711
+ graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
712
+ localStorageCacheRepository.setSingle.mockResolvedValue();
713
+
714
+ const result = await repository.getAllIssues('cached-project');
715
+
716
+ expect(
717
+ graphqlProjectItemRepository.fetchProjectItems,
718
+ ).toHaveBeenCalledWith('cached-project');
719
+ expect(
720
+ graphqlProjectItemRepository.fetchProjectItemsLight,
721
+ ).not.toHaveBeenCalled();
722
+ expect(result.cacheUsed).toBe(false);
723
+ });
724
+
725
+ it('escalates to full fetch when a story option is removed in the fresh project', async () => {
726
+ const {
727
+ repository,
728
+ graphqlProjectItemRepository,
729
+ localStorageCacheRepository,
730
+ projectRepository,
731
+ dateRepository,
732
+ } = createApiV3CheerioRestIssueRepository();
733
+ const cachedProject = buildProjectWithStories('cached-project', [
734
+ { id: 'opt-1', name: 'Story A' },
735
+ { id: 'opt-2', name: 'Story B' },
736
+ ]);
737
+ const freshProject = buildProjectWithStories('cached-project', [
738
+ { id: 'opt-1', name: 'Story A' },
739
+ ]);
740
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
741
+ localStorageCacheRepository.getSingle.mockResolvedValue({
742
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
743
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
744
+ project: cachedProject,
745
+ issues: [],
746
+ });
747
+ projectRepository.getProject.mockResolvedValue(freshProject);
748
+ graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
749
+ localStorageCacheRepository.setSingle.mockResolvedValue();
750
+
751
+ const result = await repository.getAllIssues('cached-project');
752
+
753
+ expect(
754
+ graphqlProjectItemRepository.fetchProjectItems,
755
+ ).toHaveBeenCalledWith('cached-project');
756
+ expect(result.cacheUsed).toBe(false);
757
+ });
758
+
759
+ it('proceeds with incremental fetch when story options are unchanged', async () => {
760
+ const {
761
+ repository,
762
+ graphqlProjectItemRepository,
763
+ localStorageCacheRepository,
764
+ projectRepository,
765
+ dateRepository,
766
+ } = createApiV3CheerioRestIssueRepository();
767
+ const cachedProject = buildProjectWithStories('cached-project', [
768
+ { id: 'opt-1', name: 'Story A' },
769
+ ]);
770
+ const freshProject = buildProjectWithStories('cached-project', [
771
+ { id: 'opt-1', name: 'Story A' },
772
+ ]);
773
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
774
+ localStorageCacheRepository.getSingle.mockResolvedValue({
775
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
776
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
777
+ project: cachedProject,
778
+ issues: [],
779
+ });
780
+ projectRepository.getProject.mockResolvedValue(freshProject);
781
+ graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
782
+ localStorageCacheRepository.setSingle.mockResolvedValue();
783
+
784
+ const result = await repository.getAllIssues('cached-project');
785
+
786
+ expect(
787
+ graphqlProjectItemRepository.fetchProjectItems,
788
+ ).not.toHaveBeenCalled();
789
+ expect(result.cacheUsed).toBe(true);
790
+ });
791
+
792
+ it('writes the fresh project (not cache.project) to cache during incremental fetch', async () => {
793
+ const {
794
+ repository,
795
+ graphqlProjectItemRepository,
796
+ localStorageCacheRepository,
797
+ projectRepository,
798
+ dateRepository,
799
+ } = createApiV3CheerioRestIssueRepository();
800
+ const cachedProject = buildProjectWithStories('cached-project', [
801
+ { id: 'opt-1', name: 'Story A' },
802
+ ]);
803
+ const freshProject = buildProjectWithStories('cached-project', [
804
+ { id: 'opt-1', name: 'Story A' },
805
+ { id: 'opt-2', name: 'Story B (new)' },
806
+ ]);
807
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
808
+ localStorageCacheRepository.getSingle.mockResolvedValue({
809
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
810
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
811
+ project: cachedProject,
812
+ issues: [],
813
+ });
814
+ projectRepository.getProject.mockResolvedValue(freshProject);
815
+ graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
816
+ localStorageCacheRepository.setSingle.mockResolvedValue();
817
+
818
+ await repository.getAllIssues('cached-project');
819
+
820
+ const cacheWrite = localStorageCacheRepository.setSingle.mock.calls[0][1];
821
+ expect(cacheWrite).toEqual(
822
+ expect.objectContaining({ project: freshProject }),
823
+ );
824
+ });
825
+
826
+ it('falls back to cache.project and logs a warning when getProject fails during incremental fetch', async () => {
827
+ const {
828
+ repository,
829
+ graphqlProjectItemRepository,
830
+ localStorageCacheRepository,
831
+ projectRepository,
832
+ dateRepository,
833
+ } = createApiV3CheerioRestIssueRepository();
834
+ const cachedProject = buildTestProject('cached-project');
835
+ const warnSpy = jest
836
+ .spyOn(console, 'warn')
837
+ .mockImplementation(() => undefined);
838
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
839
+ localStorageCacheRepository.getSingle.mockResolvedValue({
840
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
841
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
842
+ project: cachedProject,
843
+ issues: [],
844
+ });
845
+ projectRepository.getProject.mockRejectedValue(
846
+ new Error('network error'),
847
+ );
848
+ graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
849
+ localStorageCacheRepository.setSingle.mockResolvedValue();
850
+
851
+ const result = await repository.getAllIssues('cached-project');
852
+
853
+ expect(result.cacheUsed).toBe(true);
854
+ expect(result.project).toBe(cachedProject);
855
+ expect(warnSpy).toHaveBeenCalledWith(
856
+ expect.stringContaining('Failed to refresh project metadata'),
857
+ );
858
+ expect(warnSpy).toHaveBeenCalledWith(
859
+ expect.stringContaining('cached-project'),
860
+ );
861
+ warnSpy.mockRestore();
862
+ });
863
+
864
+ it('re-throws the error when getProject fails during a full fetch with no cache', async () => {
865
+ const {
866
+ repository,
867
+ localStorageCacheRepository,
868
+ projectRepository,
869
+ dateRepository,
870
+ } = createApiV3CheerioRestIssueRepository();
871
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
872
+ localStorageCacheRepository.getSingle.mockResolvedValue(null);
873
+ projectRepository.getProject.mockRejectedValue(
874
+ new Error('project fetch failed'),
875
+ );
876
+
877
+ await expect(repository.getAllIssues('cached-project')).rejects.toThrow(
878
+ 'project fetch failed',
879
+ );
880
+ });
881
+ });
882
+
621
883
  describe('getLastIssuesFetchedAt', () => {
622
884
  it('reports null before any fetch has happened for the project', () => {
623
885
  const { repository } = createApiV3CheerioRestIssueRepository();
@@ -653,6 +915,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
653
915
  repository,
654
916
  graphqlProjectItemRepository,
655
917
  localStorageCacheRepository,
918
+ projectRepository,
656
919
  dateRepository,
657
920
  } = createApiV3CheerioRestIssueRepository();
658
921
  dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
@@ -662,6 +925,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
662
925
  project: buildTestProject('cached-project'),
663
926
  issues: [],
664
927
  });
928
+ projectRepository.getProject.mockResolvedValue(
929
+ buildTestProject('cached-project'),
930
+ );
665
931
  graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
666
932
  localStorageCacheRepository.setSingle.mockResolvedValue();
667
933
 
@@ -808,11 +808,41 @@ export class ApiV3CheerioRestIssueRepository
808
808
  now.getTime() - new Date(cache.lastFullFetchAt).getTime() >=
809
809
  FULL_ISSUE_FETCH_INTERVAL_MS;
810
810
 
811
- if (isFullFetch) {
812
- const project = await this.projectRepository.getProject(projectId);
813
- if (!project) {
811
+ let project: Project;
812
+ try {
813
+ const freshProject = await this.projectRepository.getProject(projectId);
814
+ if (!freshProject) {
814
815
  throw new Error(`Project not found. projectId: ${projectId}`);
815
816
  }
817
+ project = freshProject;
818
+ } catch (error) {
819
+ if (!isFullFetch && cache !== null) {
820
+ console.warn(
821
+ `Failed to refresh project metadata, using cached. projectId: ${projectId}, error: ${String(error)}`,
822
+ );
823
+ project = cache.project;
824
+ } else {
825
+ throw error;
826
+ }
827
+ }
828
+
829
+ const cacheStoriesByOptionId = new Map(
830
+ cache?.project.story?.stories.map((s) => [s.id, s.name]) ?? [],
831
+ );
832
+ const freshStoryOptionIds = new Set(
833
+ project.story?.stories.map((s) => s.id) ?? [],
834
+ );
835
+ const storyOptionsChanged =
836
+ cache !== null &&
837
+ ([...cacheStoriesByOptionId.entries()].some(
838
+ ([id, name]) =>
839
+ !freshStoryOptionIds.has(id) ||
840
+ project.story?.stories.find((s) => s.id === id)?.name !== name,
841
+ ) ??
842
+ false);
843
+ const effectiveIsFullFetch = isFullFetch || storyOptionsChanged;
844
+
845
+ if (effectiveIsFullFetch) {
816
846
  const items =
817
847
  await this.graphqlProjectItemRepository.fetchProjectItems(projectId);
818
848
  const issues = items.map((item) => this.convertProjectItemToIssue(item));
@@ -827,7 +857,6 @@ export class ApiV3CheerioRestIssueRepository
827
857
  return { issues, project, cacheUsed: false };
828
858
  }
829
859
 
830
- const project = cache.project;
831
860
  const lastFetchedAt = new Date(cache.lastFetchedAt);
832
861
  const cutoff = new Date(
833
862
  lastFetchedAt.getTime() - INCREMENTAL_FETCH_SKEW_BUFFER_MS,
@@ -26,7 +26,7 @@ export class AnalyzeProblemByIssueUseCase {
26
26
  !story ||
27
27
  !input.targetDates.find(
28
28
  (targetDate) =>
29
- targetDate.getHours() === 0 && targetDate.getMinutes() === 0,
29
+ targetDate.getUTCHours() === 0 && targetDate.getUTCMinutes() === 0,
30
30
  )
31
31
  ) {
32
32
  return;
@@ -0,0 +1,80 @@
1
+ import { mock } from 'jest-mock-extended';
2
+ import { AnalyzeStoriesUseCase } from './AnalyzeStoriesUseCase';
3
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
+ import { DateRepository } from './adapter-interfaces/DateRepository';
5
+ import { Project } from '../entities/Project';
6
+ import { StoryObject } from '../entities/StoryObjectMap';
7
+
8
+ describe('AnalyzeStoriesUseCase', () => {
9
+ const mockIssueRepository = mock<IssueRepository>();
10
+ const mockDateRepository = mock<DateRepository>();
11
+
12
+ let useCase: AnalyzeStoriesUseCase;
13
+
14
+ const projectWithStory: Project = {
15
+ ...mock<Project>(),
16
+ story: {
17
+ name: 'Story',
18
+ fieldId: 'story-field',
19
+ databaseId: 1,
20
+ stories: [],
21
+ workflowManagementStory: { id: 'wms-id', name: 'workflow management' },
22
+ },
23
+ };
24
+
25
+ const commonInput = {
26
+ project: projectWithStory,
27
+ issues: [],
28
+ cacheUsed: false,
29
+ manager: 'manager-user',
30
+ org: 'org',
31
+ repo: 'repo',
32
+ urlOfStoryView: 'https://github.com/org/repo',
33
+ storyObjectMap: new Map<string, StoryObject>(),
34
+ members: [],
35
+ };
36
+
37
+ beforeEach(() => {
38
+ jest.clearAllMocks();
39
+ useCase = new AnalyzeStoriesUseCase(
40
+ mockIssueRepository,
41
+ mockDateRepository,
42
+ );
43
+ });
44
+
45
+ describe('run — UTC 05:00 guard', () => {
46
+ it('returns early when no targetDate is at UTC 05:00', async () => {
47
+ const nonMatchingDate = new Date(Date.UTC(2026, 0, 15, 4, 0, 0));
48
+
49
+ await useCase.run({ ...commonInput, targetDates: [nonMatchingDate] });
50
+
51
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('proceeds when a targetDate is exactly UTC 05:00', async () => {
55
+ const matchingDate = new Date(Date.UTC(2026, 0, 15, 5, 0, 0));
56
+
57
+ await useCase.run({ ...commonInput, targetDates: [matchingDate] });
58
+
59
+ expect(mockIssueRepository.createNewIssue).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('returns early when project has no story field', async () => {
63
+ const projectWithoutStory: Project = { ...projectWithStory, story: null };
64
+
65
+ await useCase.run({
66
+ ...commonInput,
67
+ project: projectWithoutStory,
68
+ targetDates: [new Date(Date.UTC(2026, 0, 15, 5, 0, 0))],
69
+ });
70
+
71
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('returns early when targetDates is empty', async () => {
75
+ await useCase.run({ ...commonInput, targetDates: [] });
76
+
77
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
78
+ });
79
+ });
80
+ });
@@ -30,7 +30,7 @@ export class AnalyzeStoriesUseCase {
30
30
  !story ||
31
31
  !input.targetDates.find(
32
32
  (targetDate) =>
33
- targetDate.getHours() === 5 && targetDate.getMinutes() === 0,
33
+ targetDate.getUTCHours() === 5 && targetDate.getUTCMinutes() === 0,
34
34
  )
35
35
  ) {
36
36
  return;
@@ -28,10 +28,12 @@ export class ClearPastNextActionDateHourUseCase {
28
28
  }
29
29
  const scheduledDate = issue.nextActionDate ?? now;
30
30
  const scheduledTime = new Date(
31
- scheduledDate.getFullYear(),
32
- scheduledDate.getMonth(),
33
- scheduledDate.getDate(),
34
- issue.nextActionHour,
31
+ Date.UTC(
32
+ scheduledDate.getUTCFullYear(),
33
+ scheduledDate.getUTCMonth(),
34
+ scheduledDate.getUTCDate(),
35
+ issue.nextActionHour,
36
+ ),
35
37
  );
36
38
  if (scheduledTime.getTime() > now.getTime()) {
37
39
  continue;