@link-assistant/hive-mind 1.2.9 → 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.2.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 8404b75: fix: Support weekly limit date parsing in extractResetTime and parseResetTime
8
+ - Added Pattern 0 to extractResetTime() to handle date+time formats like "resets Jan 15, 8am"
9
+ - Updated parseResetTime() to parse date+time strings with month name and day
10
+ - This ensures weekly limit messages are displayed with the "Usage Limit Reached" format
11
+
12
+ ## 1.2.10
13
+
14
+ ### Patch Changes
15
+
16
+ - 7ba1476: Auto-cleanup .playwright-mcp/ folder to prevent false auto-restart triggers
17
+ - Add auto-cleanup of .playwright-mcp/ folder before checking uncommitted changes
18
+ - Add --playwright-mcp-auto-cleanup option (enabled by default)
19
+ - Use --no-playwright-mcp-auto-cleanup to disable cleanup for debugging
20
+ - Add comprehensive case study documentation for issue #1124
21
+
3
22
  ## 1.2.9
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,8 +13,9 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "node tests/solve-queue.test.mjs",
16
+ "test": "node tests/solve-queue.test.mjs && node tests/test-usage-limit.mjs",
17
17
  "test:queue": "node tests/solve-queue.test.mjs",
18
+ "test:usage-limit": "node tests/test-usage-limit.mjs",
18
19
  "lint": "eslint 'src/**/*.{js,mjs,cjs}'",
19
20
  "lint:fix": "eslint 'src/**/*.{js,mjs,cjs}' --fix",
20
21
  "format": "prettier --write \"**/*.{js,mjs,json,md}\"",
@@ -65,6 +66,7 @@
65
66
  "@secretlint/secretlint-rule-preset-recommend": "^11.2.5",
66
67
  "@sentry/node": "^10.15.0",
67
68
  "@sentry/profiling-node": "^10.15.0",
69
+ "dayjs": "^1.11.19",
68
70
  "secretlint": "^11.2.5"
69
71
  },
70
72
  "lint-staged": {
@@ -896,6 +896,7 @@ export const executeClaudeCommand = async params => {
896
896
  let sessionId = null;
897
897
  let limitReached = false;
898
898
  let limitResetTime = null;
899
+ let limitTimezone = null;
899
900
  let messageCount = 0;
900
901
  let toolUseCount = 0;
901
902
  let lastMessage = '';
@@ -1162,6 +1163,7 @@ export const executeClaudeCommand = async params => {
1162
1163
  sessionId,
1163
1164
  limitReached: false,
1164
1165
  limitResetTime: null,
1166
+ limitTimezone: null,
1165
1167
  messageCount,
1166
1168
  toolUseCount,
1167
1169
  };
@@ -1208,6 +1210,7 @@ export const executeClaudeCommand = async params => {
1208
1210
  sessionId,
1209
1211
  limitReached: false,
1210
1212
  limitResetTime: null,
1213
+ limitTimezone: null,
1211
1214
  messageCount,
1212
1215
  toolUseCount,
1213
1216
  is503Error: true,
@@ -1220,6 +1223,7 @@ export const executeClaudeCommand = async params => {
1220
1223
  if (limitInfo.isUsageLimit) {
1221
1224
  limitReached = true;
1222
1225
  limitResetTime = limitInfo.resetTime;
1226
+ limitTimezone = limitInfo.timezone;
1223
1227
 
1224
1228
  // Format and display user-friendly message
1225
1229
  const messageLines = formatUsageLimitMessage({
@@ -1284,6 +1288,7 @@ export const executeClaudeCommand = async params => {
1284
1288
  sessionId,
1285
1289
  limitReached,
1286
1290
  limitResetTime,
1291
+ limitTimezone,
1287
1292
  messageCount,
1288
1293
  toolUseCount,
1289
1294
  errorDuringExecution,
@@ -1353,6 +1358,7 @@ export const executeClaudeCommand = async params => {
1353
1358
  sessionId,
1354
1359
  limitReached,
1355
1360
  limitResetTime,
1361
+ limitTimezone,
1356
1362
  messageCount,
1357
1363
  toolUseCount,
1358
1364
  anthropicTotalCostUSD, // Pass Anthropic's official total cost
@@ -1401,6 +1407,7 @@ export const executeClaudeCommand = async params => {
1401
1407
  sessionId,
1402
1408
  limitReached,
1403
1409
  limitResetTime: null,
1410
+ limitTimezone: null,
1404
1411
  messageCount,
1405
1412
  toolUseCount,
1406
1413
  };
@@ -9,6 +9,11 @@ import { readFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { promisify } from 'node:util';
12
+ import dayjs from 'dayjs';
13
+ import utc from 'dayjs/plugin/utc.js';
14
+
15
+ // Initialize dayjs plugins
16
+ dayjs.extend(utc);
12
17
 
13
18
  // Import cache TTL configuration
14
19
  import { cacheTtl } from './config.lib.mjs';
@@ -51,7 +56,7 @@ async function readCredentials(credentialsPath = DEFAULT_CREDENTIALS_PATH, verbo
51
56
  }
52
57
 
53
58
  /**
54
- * Format an ISO date string to a human-readable reset time
59
+ * Format an ISO date string to a human-readable reset time using dayjs
55
60
  *
56
61
  * @param {string} isoDate - ISO date string (e.g., "2025-12-03T17:59:59.626485+00:00")
57
62
  * @param {boolean} includeTimezone - Whether to include timezone suffix (default: true)
@@ -61,18 +66,11 @@ function formatResetTime(isoDate, includeTimezone = true) {
61
66
  if (!isoDate) return null;
62
67
 
63
68
  try {
64
- const date = new Date(isoDate);
65
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
66
- const month = months[date.getUTCMonth()];
67
- const day = date.getUTCDate();
68
- const hours = date.getUTCHours();
69
- const minutes = date.getUTCMinutes();
70
-
71
- // Convert 24h to 12h format
72
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
73
- const ampm = hours >= 12 ? 'pm' : 'am';
74
-
75
- const timeStr = `${month} ${day}, ${hour12}:${minutes.toString().padStart(2, '0')}${ampm}`;
69
+ const date = dayjs(isoDate).utc();
70
+ if (!date.isValid()) return isoDate;
71
+
72
+ // dayjs format: MMM=Jan, D=day, h=12-hour, mm=minutes, a=am/pm
73
+ const timeStr = date.format('MMM D, h:mma');
76
74
  return includeTimezone ? `${timeStr} UTC` : timeStr;
77
75
  } catch {
78
76
  return isoDate;
@@ -80,7 +78,7 @@ function formatResetTime(isoDate, includeTimezone = true) {
80
78
  }
81
79
 
82
80
  /**
83
- * Format relative time from now to a future date
81
+ * Format relative time from now to a future date using dayjs
84
82
  *
85
83
  * @param {string} isoDate - ISO date string
86
84
  * @returns {string|null} Relative time string (e.g., "1h 34m" or "6d 20h 13m") or null if date is in the past
@@ -89,49 +87,40 @@ function formatRelativeTime(isoDate) {
89
87
  if (!isoDate) return null;
90
88
 
91
89
  try {
92
- const now = new Date();
93
- const target = new Date(isoDate);
94
- const diffMs = target - now;
90
+ const now = dayjs();
91
+ const target = dayjs(isoDate);
95
92
 
96
- // Check for invalid date (NaN)
97
- if (isNaN(diffMs)) return null;
93
+ if (!target.isValid()) return null;
98
94
 
95
+ const diffMs = target.diff(now);
99
96
  if (diffMs < 0) return null; // Past date
100
97
 
101
- const totalHours = Math.floor(diffMs / (1000 * 60 * 60));
102
- const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
98
+ const totalMinutes = Math.floor(diffMs / (1000 * 60));
99
+ const totalHours = Math.floor(totalMinutes / 60);
100
+ const totalDays = Math.floor(totalHours / 24);
101
+
102
+ const days = totalDays;
103
+ const hours = totalHours % 24;
104
+ const minutes = totalMinutes % 60;
103
105
 
104
106
  // If hours >= 24, show days
105
- if (totalHours >= 24) {
106
- const days = Math.floor(totalHours / 24);
107
- const hours = totalHours % 24;
107
+ if (days > 0) {
108
108
  return `${days}d ${hours}h ${minutes}m`;
109
109
  }
110
110
 
111
- return `${totalHours}h ${minutes}m`;
111
+ return `${hours}h ${minutes}m`;
112
112
  } catch {
113
113
  return null;
114
114
  }
115
115
  }
116
116
 
117
117
  /**
118
- * Format current time in UTC
118
+ * Format current time in UTC using dayjs
119
119
  *
120
120
  * @returns {string} Current time in UTC (e.g., "Dec 3, 6:45pm UTC")
121
121
  */
122
122
  function formatCurrentTime() {
123
- const now = new Date();
124
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
125
- const month = months[now.getUTCMonth()];
126
- const day = now.getUTCDate();
127
- const hours = now.getUTCHours();
128
- const minutes = now.getUTCMinutes();
129
-
130
- // Convert 24h to 12h format
131
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
132
- const ampm = hours >= 12 ? 'pm' : 'am';
133
-
134
- return `${month} ${day}, ${hour12}:${minutes.toString().padStart(2, '0')}${ampm} UTC`;
123
+ return dayjs().utc().format('MMM D, h:mma [UTC]');
135
124
  }
136
125
 
137
126
  /**
@@ -311,6 +311,11 @@ export const createYargsConfig = yargsInstance => {
311
311
  description: 'Include prompt to check related/sibling pull requests when studying related work. Enabled by default, use --no-prompt-check-sibling-pull-requests to disable.',
312
312
  default: true,
313
313
  })
314
+ .option('playwright-mcp-auto-cleanup', {
315
+ type: 'boolean',
316
+ description: 'Automatically remove .playwright-mcp/ folder before checking for uncommitted changes. This prevents browser automation artifacts from triggering auto-restart. Use --no-playwright-mcp-auto-cleanup to keep the folder for debugging.',
317
+ default: true,
318
+ })
314
319
  .parserConfiguration({
315
320
  'boolean-negation': true,
316
321
  })
package/src/solve.mjs CHANGED
@@ -898,10 +898,13 @@ try {
898
898
  limitReached = toolResult.limitReached;
899
899
  cleanupContext.limitReached = limitReached;
900
900
 
901
- // Capture limit reset time globally for downstream handlers (auto-continue, cleanup decisions)
901
+ // Capture limit reset time and timezone globally for downstream handlers (auto-continue, cleanup decisions)
902
902
  if (toolResult && toolResult.limitResetTime) {
903
903
  global.limitResetTime = toolResult.limitResetTime;
904
904
  }
905
+ if (toolResult && toolResult.limitTimezone) {
906
+ global.limitTimezone = toolResult.limitTimezone;
907
+ }
905
908
 
906
909
  // Handle limit reached scenario
907
910
  if (limitReached) {
@@ -974,8 +977,9 @@ try {
974
977
  const tool = argv.tool || 'claude';
975
978
  const resumeCmd = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : null;
976
979
  const resumeSection = resumeCmd ? `To resume after the limit resets, use:\n\`\`\`bash\n${resumeCmd}\n\`\`\`` : `Session ID: \`${sessionId}\``;
977
- // Format the reset time with relative time if available
978
- const formattedResetTime = resetTime ? formatResetTimeWithRelative(resetTime) : null;
980
+ // Format the reset time with relative time and UTC conversion if available
981
+ const timezone = global.limitTimezone || null;
982
+ const formattedResetTime = resetTime ? formatResetTimeWithRelative(resetTime, timezone) : null;
979
983
  const failureComment = formattedResetTime ? `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. The limit will reset at: **${formattedResetTime}**\n\n${resumeSection}` : `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. Please wait for the limit to reset.\n\n${resumeSection}`;
980
984
 
981
985
  const commentResult = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${failureComment}`;
@@ -1116,6 +1120,27 @@ try {
1116
1120
  await safeExit(1, `${argv.tool.toUpperCase()} execution failed`);
1117
1121
  }
1118
1122
 
1123
+ // Clean up .playwright-mcp/ folder before checking for uncommitted changes
1124
+ // This prevents browser automation artifacts from triggering auto-restart (Issue #1124)
1125
+ if (argv.playwrightMcpAutoCleanup !== false) {
1126
+ const playwrightMcpDir = path.join(tempDir, '.playwright-mcp');
1127
+ try {
1128
+ const playwrightMcpExists = await fs
1129
+ .stat(playwrightMcpDir)
1130
+ .then(() => true)
1131
+ .catch(() => false);
1132
+ if (playwrightMcpExists) {
1133
+ await fs.rm(playwrightMcpDir, { recursive: true, force: true });
1134
+ await log('🧹 Cleaned up .playwright-mcp/ folder (browser automation artifacts)', { verbose: true });
1135
+ }
1136
+ } catch (cleanupError) {
1137
+ // Non-critical error, just log and continue
1138
+ await log(`⚠️ Could not clean up .playwright-mcp/ folder: ${cleanupError.message}`, { verbose: true });
1139
+ }
1140
+ } else {
1141
+ await log('ℹ️ Playwright MCP auto-cleanup disabled via --no-playwright-mcp-auto-cleanup', { verbose: true });
1142
+ }
1143
+
1119
1144
  // Check for uncommitted changes
1120
1145
  // When limit is reached, force auto-commit of any uncommitted changes to preserve work
1121
1146
  const shouldAutoCommit = argv['auto-commit-uncommitted-changes'] || limitReached;
@@ -15,6 +15,10 @@ const use = globalThis.use;
15
15
  // Use command-stream for consistent $ behavior across runtimes
16
16
  const { $ } = await use('command-stream');
17
17
 
18
+ // Import path and fs for cleanup operations
19
+ const path = (await use('path')).default;
20
+ const fs = (await use('fs')).promises;
21
+
18
22
  // Import shared library functions
19
23
  const lib = await import('./lib.mjs');
20
24
  const { log, cleanErrorMessage, formatAligned } = lib;
@@ -50,10 +54,36 @@ const checkPRMerged = async (owner, repo, prNumber) => {
50
54
  return false;
51
55
  };
52
56
 
57
+ /**
58
+ * Clean up .playwright-mcp/ folder to prevent browser automation artifacts
59
+ * from triggering auto-restart (Issue #1124)
60
+ */
61
+ const cleanupPlaywrightMcpFolder = async (tempDir, argv) => {
62
+ if (argv.playwrightMcpAutoCleanup !== false) {
63
+ const playwrightMcpDir = path.join(tempDir, '.playwright-mcp');
64
+ try {
65
+ const playwrightMcpExists = await fs
66
+ .stat(playwrightMcpDir)
67
+ .then(() => true)
68
+ .catch(() => false);
69
+ if (playwrightMcpExists) {
70
+ await fs.rm(playwrightMcpDir, { recursive: true, force: true });
71
+ await log('🧹 Cleaned up .playwright-mcp/ folder (browser automation artifacts)', { verbose: true });
72
+ }
73
+ } catch (cleanupError) {
74
+ // Non-critical error, just log and continue
75
+ await log(`⚠️ Could not clean up .playwright-mcp/ folder: ${cleanupError.message}`, { verbose: true });
76
+ }
77
+ }
78
+ };
79
+
53
80
  /**
54
81
  * Check if there are uncommitted changes in the repository
55
82
  */
56
- const checkForUncommittedChanges = async (tempDir, $) => {
83
+ const checkForUncommittedChanges = async (tempDir, $, argv = {}) => {
84
+ // First, clean up .playwright-mcp/ folder to prevent false positives (Issue #1124)
85
+ await cleanupPlaywrightMcpFolder(tempDir, argv);
86
+
57
87
  try {
58
88
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
59
89
  if (gitStatusResult.code === 0) {
@@ -130,7 +160,7 @@ export const watchForFeedback = async params => {
130
160
 
131
161
  // In temporary watch mode, check if all changes have been committed
132
162
  if (isTemporaryWatch && !firstIterationInTemporaryMode) {
133
- const hasUncommitted = await checkForUncommittedChanges(tempDir, $);
163
+ const hasUncommitted = await checkForUncommittedChanges(tempDir, $, argv);
134
164
  if (!hasUncommitted) {
135
165
  await log('');
136
166
  await log(formatAligned('✅', 'CHANGES COMMITTED!', 'Exiting auto-restart mode'));
@@ -185,7 +215,7 @@ export const watchForFeedback = async params => {
185
215
  // In temporary watch mode, also check for uncommitted changes as a restart trigger
186
216
  let hasUncommittedInTempMode = false;
187
217
  if (isTemporaryWatch && !firstIterationInTemporaryMode) {
188
- hasUncommittedInTempMode = await checkForUncommittedChanges(tempDir, $);
218
+ hasUncommittedInTempMode = await checkForUncommittedChanges(tempDir, $, argv);
189
219
  }
190
220
 
191
221
  const shouldRestart = hasFeedback || firstIterationInTemporaryMode || hasUncommittedInTempMode;
@@ -4,9 +4,21 @@
4
4
  * This module provides utilities for detecting and handling usage limit errors
5
5
  * from AI tools (Claude, Codex, OpenCode).
6
6
  *
7
- * Related issue: https://github.com/link-assistant/hive-mind/issues/719
7
+ * Related issues:
8
+ * - https://github.com/link-assistant/hive-mind/issues/719 (original)
9
+ * - https://github.com/link-assistant/hive-mind/issues/1122 (weekly limit date parsing with timezone)
8
10
  */
9
11
 
12
+ import dayjs from 'dayjs';
13
+ import utc from 'dayjs/plugin/utc.js';
14
+ import timezone from 'dayjs/plugin/timezone.js';
15
+ import customParseFormat from 'dayjs/plugin/customParseFormat.js';
16
+
17
+ // Initialize dayjs plugins
18
+ dayjs.extend(utc);
19
+ dayjs.extend(timezone);
20
+ dayjs.extend(customParseFormat);
21
+
10
22
  /**
11
23
  * Detect if an error message indicates a usage limit has been reached
12
24
  *
@@ -46,11 +58,48 @@ export function isUsageLimitError(message) {
46
58
  return patterns.some(pattern => lowerMessage.includes(pattern));
47
59
  }
48
60
 
61
+ /**
62
+ * Extract timezone from usage limit error message
63
+ *
64
+ * Extracts IANA timezone identifiers like "Europe/Berlin", "UTC", "America/New_York"
65
+ * from messages like "resets Jan 15, 8am (Europe/Berlin)"
66
+ *
67
+ * @param {string} message - Error message to analyze
68
+ * @returns {string|null} - Timezone string or null if not found
69
+ */
70
+ export function extractTimezone(message) {
71
+ if (!message || typeof message !== 'string') {
72
+ return null;
73
+ }
74
+
75
+ // Pattern: (Timezone) - matches IANA timezone format or "UTC"
76
+ // IANA format: Continent/City or Continent/Region/City
77
+ const timezoneMatch = message.match(/\(([A-Za-z_]+(?:\/[A-Za-z_]+){0,2})\)/);
78
+ if (timezoneMatch) {
79
+ const tz = timezoneMatch[1];
80
+ // Validate it's a recognizable timezone by trying to use it with dayjs
81
+ try {
82
+ const testDate = dayjs().tz(tz);
83
+ if (testDate.isValid()) {
84
+ return tz;
85
+ }
86
+ } catch {
87
+ // Invalid timezone, return null
88
+ }
89
+ }
90
+
91
+ return null;
92
+ }
93
+
49
94
  /**
50
95
  * Extract reset time from usage limit error message
51
96
  *
97
+ * Supports both time-only formats (5-hour limits) and date+time formats (weekly limits):
98
+ * - "resets 10pm" → "10:00 PM"
99
+ * - "resets Jan 15, 8am" → "Jan 15, 8:00 AM"
100
+ *
52
101
  * @param {string} message - Error message to analyze
53
- * @returns {string|null} - Reset time string (e.g., "12:16 PM") or null if not found
102
+ * @returns {string|null} - Reset time string (e.g., "12:16 PM" or "Jan 15, 8:00 AM") or null if not found
54
103
  */
55
104
  export function extractResetTime(message) {
56
105
  if (!message || typeof message !== 'string') {
@@ -60,6 +109,21 @@ export function extractResetTime(message) {
60
109
  // Normalize whitespace for easier matching
61
110
  const normalized = message.replace(/\s+/g, ' ');
62
111
 
112
+ // Pattern 0: Weekly limit with date - "resets Jan 15, 8am" or "resets January 15, 8:00am"
113
+ // This pattern must come first to avoid partial matches by time-only patterns
114
+ const monthPattern = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
115
+ const resetsWithDateRegex = new RegExp(`resets\\s+(${monthPattern})\\s+(\\d{1,2}),?\\s+([0-9]{1,2})(?::([0-9]{2}))?\\s*([ap]m)`, 'i');
116
+ const resetsWithDate = normalized.match(resetsWithDateRegex);
117
+ if (resetsWithDate) {
118
+ const month = resetsWithDate[1];
119
+ const day = resetsWithDate[2];
120
+ const hour = resetsWithDate[3];
121
+ const minute = resetsWithDate[4] || '00';
122
+ const ampm = resetsWithDate[5].toUpperCase();
123
+ // Return formatted date+time string for weekly limits
124
+ return `${month} ${day}, ${hour}:${minute} ${ampm}`;
125
+ }
126
+
63
127
  // Pattern 1: "try again at 12:16 PM"
64
128
  const tryAgainMatch = normalized.match(/try again at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
65
129
  if (tryAgainMatch) {
@@ -130,78 +194,142 @@ export function extractResetTime(message) {
130
194
  * Detect usage limit error and extract all relevant information
131
195
  *
132
196
  * @param {string} message - Error message to analyze
133
- * @returns {Object} - { isUsageLimit: boolean, resetTime: string|null }
197
+ * @returns {Object} - { isUsageLimit: boolean, resetTime: string|null, timezone: string|null }
134
198
  */
135
199
  export function detectUsageLimit(message) {
136
200
  const isUsageLimit = isUsageLimitError(message);
137
201
  const resetTime = isUsageLimit ? extractResetTime(message) : null;
202
+ const timezone = isUsageLimit ? extractTimezone(message) : null;
138
203
 
139
204
  return {
140
205
  isUsageLimit,
141
206
  resetTime,
207
+ timezone,
142
208
  };
143
209
  }
144
210
 
145
211
  /**
146
- * Parse time string (e.g., "11:00 PM") and convert to Date object for today
212
+ * Parse time string and convert to dayjs object using dayjs custom parse format
213
+ *
214
+ * Supports both formats:
215
+ * - Time only: "11:00 PM" → today or tomorrow at that time
216
+ * - Date+time: "Jan 15, 8:00 AM" → specific date at that time
217
+ *
218
+ * Uses dayjs customParseFormat plugin for cleaner parsing.
147
219
  *
148
- * @param {string} timeStr - Time string in format "HH:MM AM/PM"
149
- * @returns {Date|null} - Date object or null if parsing fails
220
+ * @param {string} timeStr - Time string in format "HH:MM AM/PM" or "Mon DD, HH:MM AM/PM"
221
+ * @param {string|null} tz - Optional IANA timezone (e.g., "Europe/Berlin")
222
+ * @returns {dayjs.Dayjs|null} - dayjs object or null if parsing fails
150
223
  */
151
- export function parseResetTime(timeStr) {
224
+ export function parseResetTime(timeStr, tz = null) {
152
225
  if (!timeStr || typeof timeStr !== 'string') {
153
226
  return null;
154
227
  }
155
228
 
156
- // Match pattern like "11:00 PM" or "11:00PM"
157
- const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
158
- if (!match) {
159
- return null;
160
- }
229
+ const now = dayjs();
230
+
231
+ // Normalize "Sept" to "Sep" for dayjs compatibility
232
+ const normalized = timeStr.replace(/\bSept\b/gi, 'Sep');
161
233
 
162
- let hour = parseInt(match[1], 10);
163
- const minute = parseInt(match[2], 10);
164
- const ampm = match[3].toUpperCase();
234
+ // Try date+time formats using dayjs custom parse
235
+ // dayjs uses: MMM=Jan, MMMM=January, D=day, h=12-hour, mm=minutes, A=AM/PM
236
+ const dateTimeFormats = ['MMM D, h:mm A', 'MMMM D, h:mm A'];
237
+
238
+ for (const format of dateTimeFormats) {
239
+ let parsed;
240
+ if (tz) {
241
+ try {
242
+ // Parse in the specified timezone
243
+ parsed = dayjs.tz(normalized, format, tz);
244
+ } catch {
245
+ parsed = dayjs(normalized, format);
246
+ }
247
+ } else {
248
+ parsed = dayjs(normalized, format);
249
+ }
165
250
 
166
- // Convert to 24-hour format
167
- if (ampm === 'PM' && hour !== 12) {
168
- hour += 12;
169
- } else if (ampm === 'AM' && hour === 12) {
170
- hour = 0;
251
+ if (parsed.isValid()) {
252
+ // dayjs parses without year, so it defaults to current year
253
+ // If the date is in the past, assume next year
254
+ if (parsed.isBefore(now)) {
255
+ parsed = parsed.add(1, 'year');
256
+ }
257
+ return parsed;
258
+ }
171
259
  }
172
260
 
173
- // Create date for today with the parsed time
174
- const now = new Date();
175
- const resetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
261
+ // Try time-only format: "8:00 PM" or "8:00PM"
262
+ const timeOnlyFormats = ['h:mm A', 'h:mmA'];
263
+
264
+ for (const format of timeOnlyFormats) {
265
+ let parsed;
266
+ if (tz) {
267
+ try {
268
+ parsed = dayjs.tz(normalized, format, tz);
269
+ } catch {
270
+ parsed = dayjs(normalized, format);
271
+ }
272
+ } else {
273
+ parsed = dayjs(normalized, format);
274
+ }
275
+
276
+ if (parsed.isValid()) {
277
+ // For time-only, set to today's date
278
+ parsed = parsed.year(now.year()).month(now.month()).date(now.date());
279
+
280
+ // Re-apply timezone after setting date components
281
+ if (tz) {
282
+ try {
283
+ const dateStr = parsed.format('YYYY-MM-DD HH:mm');
284
+ parsed = dayjs.tz(dateStr, tz);
285
+ } catch {
286
+ // Keep the parsed value
287
+ }
288
+ }
176
289
 
177
- // If the time is in the past today, assume it's tomorrow
178
- if (resetDate <= now) {
179
- resetDate.setDate(resetDate.getDate() + 1);
290
+ // If the time is in the past today, assume tomorrow
291
+ if (parsed.isBefore(now)) {
292
+ parsed = parsed.add(1, 'day');
293
+ }
294
+ return parsed;
295
+ }
180
296
  }
181
297
 
182
- return resetDate;
298
+ return null;
183
299
  }
184
300
 
185
301
  /**
186
302
  * Format relative time (e.g., "in 1h 23m")
187
303
  *
188
- * @param {Date} resetDate - Date object for reset time
304
+ * Uses dayjs for accurate time difference calculations.
305
+ * Accepts both Date objects and dayjs objects.
306
+ *
307
+ * @param {Date|dayjs.Dayjs} resetDate - Date or dayjs object for reset time
189
308
  * @returns {string} - Formatted relative time string
190
309
  */
191
310
  export function formatRelativeTime(resetDate) {
192
- if (!resetDate || !(resetDate instanceof Date)) {
311
+ // Accept both Date objects and dayjs objects
312
+ let resetDayjs;
313
+ if (resetDate instanceof Date) {
314
+ resetDayjs = dayjs(resetDate);
315
+ } else if (dayjs.isDayjs(resetDate)) {
316
+ resetDayjs = resetDate;
317
+ } else {
193
318
  return '';
194
319
  }
195
320
 
196
- const now = new Date();
197
- const diffMs = resetDate - now;
321
+ if (!resetDayjs.isValid()) {
322
+ return '';
323
+ }
324
+
325
+ const now = dayjs();
326
+ const diffMs = resetDayjs.diff(now);
198
327
 
199
328
  if (diffMs <= 0) {
200
329
  return 'now';
201
330
  }
202
331
 
203
- const totalSeconds = Math.floor(diffMs / 1000);
204
- const totalMinutes = Math.floor(totalSeconds / 60);
332
+ const totalMinutes = Math.floor(diffMs / (1000 * 60));
205
333
  const totalHours = Math.floor(totalMinutes / 60);
206
334
  const totalDays = Math.floor(totalHours / 24);
207
335
 
@@ -219,17 +347,20 @@ export function formatRelativeTime(resetDate) {
219
347
 
220
348
  /**
221
349
  * Format reset time with relative time and UTC time
222
- * Example: "in 1h 23m (11:00 PM UTC)"
350
+ * Example: "in 1h 23m (Jan 15, 7:00 AM UTC)"
351
+ *
352
+ * Uses dayjs for proper timezone conversion to UTC.
223
353
  *
224
- * @param {string} resetTime - Time string in format "HH:MM AM/PM"
225
- * @returns {string} - Formatted string with relative and absolute time
354
+ * @param {string} resetTime - Time string in format "HH:MM AM/PM" or "Mon DD, HH:MM AM/PM"
355
+ * @param {string|null} timezone - Optional IANA timezone (e.g., "Europe/Berlin")
356
+ * @returns {string} - Formatted string with relative and absolute UTC time
226
357
  */
227
- export function formatResetTimeWithRelative(resetTime) {
358
+ export function formatResetTimeWithRelative(resetTime, timezone = null) {
228
359
  if (!resetTime) {
229
360
  return resetTime;
230
361
  }
231
362
 
232
- const resetDate = parseResetTime(resetTime);
363
+ const resetDate = parseResetTime(resetTime, timezone);
233
364
  if (!resetDate) {
234
365
  // If we can't parse it, return the original time
235
366
  return resetTime;
@@ -237,12 +368,9 @@ export function formatResetTimeWithRelative(resetTime) {
237
368
 
238
369
  const relativeTime = formatRelativeTime(resetDate);
239
370
 
240
- // Format the UTC time
241
- const utcHours = resetDate.getUTCHours();
242
- const utcMinutes = resetDate.getUTCMinutes();
243
- const utcAmPm = utcHours >= 12 ? 'PM' : 'AM';
244
- const utcHour12 = utcHours % 12 || 12;
245
- const utcTimeStr = `${utcHour12}:${String(utcMinutes).padStart(2, '0')} ${utcAmPm} UTC`;
371
+ // Convert to UTC and format
372
+ const utcDate = resetDate.utc();
373
+ const utcTimeStr = utcDate.format('MMM D, h:mm A [UTC]');
246
374
 
247
375
  return `${relativeTime} (${utcTimeStr})`;
248
376
  }