@link-assistant/hive-mind 1.2.10 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a403c0e: Add --auto-gitkeep-file option to automatically fallback to .gitkeep when CLAUDE.md is in .gitignore
8
+
9
+ This feature pre-checks if CLAUDE.md would be ignored by .gitignore BEFORE creating the file, preventing the "paths are ignored by one of your .gitignore files" error. When detected, automatically switches to .gitkeep mode. Enabled by default (--auto-gitkeep-file=true).
10
+
11
+ ## 1.2.11
12
+
13
+ ### Patch Changes
14
+
15
+ - 8404b75: fix: Support weekly limit date parsing in extractResetTime and parseResetTime
16
+ - Added Pattern 0 to extractResetTime() to handle date+time formats like "resets Jan 15, 8am"
17
+ - Updated parseResetTime() to parse date+time strings with month name and day
18
+ - This ensures weekly limit messages are displayed with the "Usage Limit Reached" format
19
+
3
20
  ## 1.2.10
4
21
 
5
22
  ### Patch Changes
@@ -943,7 +960,7 @@
943
960
 
944
961
  This feature allows users to choose which file type to use for PR creation:
945
962
  - `--claude-file` (default: true): Use CLAUDE.md file for task details
946
- - `--gitkeep-file` (default: false, experimental): Use .gitkeep file instead
963
+ - `--gitkeep-file` (default: false): Use .gitkeep file instead
947
964
 
948
965
  The flags are mutually exclusive:
949
966
  - Using `--gitkeep-file` automatically disables `--claude-file`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.2.10",
3
+ "version": "1.3.0",
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
  /**
@@ -31,12 +31,20 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
31
31
 
32
32
  try {
33
33
  // Determine which file to create based on CLI flags
34
- const useClaudeFile = argv.claudeFile !== false; // Default to true
35
- const useGitkeepFile = argv.gitkeepFile === true; // Default to false
34
+ let useClaudeFile = argv.claudeFile !== false;
35
+ const useAutoGitkeepFile = argv.autoGitkeepFile !== false;
36
+
37
+ // Pre-check: If CLAUDE.md would be ignored by .gitignore, automatically switch to .gitkeep mode
38
+ if (useClaudeFile && useAutoGitkeepFile) {
39
+ const checkResult = await $({ cwd: tempDir, silent: true })`git check-ignore CLAUDE.md 2>/dev/null`;
40
+ if (checkResult.code === 0) {
41
+ await log(formatAligned('ℹ️', 'Pre-check:', 'CLAUDE.md is in .gitignore, switching to .gitkeep mode\n'));
42
+ useClaudeFile = false;
43
+ }
44
+ }
36
45
 
37
- // Log which mode we're using
38
46
  if (argv.verbose) {
39
- await log(` Using ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'} mode (--claude-file=${useClaudeFile}, --gitkeep-file=${useGitkeepFile})`, { verbose: true });
47
+ await log(` Using ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'} mode (--claude-file=${argv.claudeFile !== false}, --gitkeep-file=${argv.gitkeepFile === true}, --auto-gitkeep-file=${useAutoGitkeepFile})`, { verbose: true });
40
48
  }
41
49
 
42
50
  let filePath;
@@ -62,14 +70,13 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
62
70
  }
63
71
  }
64
72
  } else {
65
- // Create .gitkeep file directly (experimental mode)
66
- await log(formatAligned('📝', 'Creating:', '.gitkeep (experimental mode)'));
73
+ // .gitkeep mode (via explicit --gitkeep-file or auto-gitkeep-file fallback)
74
+ const modeDesc = argv.gitkeepFile === true ? '.gitkeep (explicit --gitkeep-file)' : '.gitkeep (CLAUDE.md is ignored)';
75
+ await log(formatAligned('📝', 'Creating:', modeDesc));
67
76
 
68
77
  filePath = path.join(tempDir, '.gitkeep');
69
78
  fileName = '.gitkeep';
70
-
71
- // .gitkeep files are typically small, no need to check for existing content
72
- // But we'll check if it exists for proper handling
79
+ // Check if .gitkeep already exists for proper handling
73
80
  try {
74
81
  existingContent = await fs.readFile(filePath, 'utf8');
75
82
  fileExisted = true;
@@ -125,12 +132,13 @@ Proceed.
125
132
  finalContent = taskInfo;
126
133
  }
127
134
  } else {
128
- // .gitkeep: Use minimal metadata format
135
+ // .gitkeep: Use minimal metadata format (explicit --gitkeep-file or auto-gitkeep-file fallback)
136
+ const creationReason = argv.gitkeepFile === true ? '# This file was created with --gitkeep-file flag' : '# This file was created because CLAUDE.md is in .gitignore (--auto-gitkeep-file=true)';
129
137
  const gitkeepContent = `# Auto-generated file for PR creation
130
138
  # Issue: ${issueUrl}
131
139
  # Branch: ${branchName}
132
140
  # Timestamp: ${timestamp}
133
- # This file was created with --gitkeep-file flag (experimental)
141
+ ${creationReason}
134
142
  # It will be removed when the task is complete`;
135
143
 
136
144
  if (fileExisted && existingContent) {
@@ -289,9 +297,8 @@ Proceed.
289
297
  }
290
298
 
291
299
  await log(formatAligned('📝', 'Creating commit:', `With ${commitFileName} file`));
292
-
293
- // Determine commit message based on which file is being committed
294
- const fileDesc = commitFileName === 'CLAUDE.md' ? 'CLAUDE.md with task information for AI processing' : `.gitkeep for PR creation (${useGitkeepFile ? 'created with --gitkeep-file flag (experimental)' : 'CLAUDE.md is in .gitignore'})`;
300
+ // Commit message distinguishes between explicit --gitkeep-file and auto-gitkeep-file fallback
301
+ const fileDesc = commitFileName === 'CLAUDE.md' ? 'CLAUDE.md with task information for AI processing' : `.gitkeep for PR creation (${argv.gitkeepFile === true ? 'created with --gitkeep-file flag' : 'CLAUDE.md is in .gitignore'})`;
295
302
  const commitMessage = `Initial commit with task details\n\nAdding ${fileDesc}.\nThis file will be removed when the task is complete.\n\nIssue: ${issueUrl}`;
296
303
 
297
304
  // Use explicit cwd option for better reliability
@@ -130,9 +130,14 @@ export const createYargsConfig = yargsInstance => {
130
130
  })
131
131
  .option('gitkeep-file', {
132
132
  type: 'boolean',
133
- description: 'Create .gitkeep file instead of CLAUDE.md (experimental, mutually exclusive with --claude-file)',
133
+ description: 'Create .gitkeep file instead of CLAUDE.md (mutually exclusive with --claude-file)',
134
134
  default: false,
135
135
  })
136
+ .option('auto-gitkeep-file', {
137
+ type: 'boolean',
138
+ description: 'Automatically use .gitkeep if CLAUDE.md is in .gitignore (pre-checks before creating file)',
139
+ default: true,
140
+ })
136
141
  .option('attach-logs', {
137
142
  type: 'boolean',
138
143
  description: 'Upload the solution draft log file to the Pull Request on completion (⚠️ WARNING: May expose sensitive data)',
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}`;
@@ -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
  }