@tuturuuu/ai 0.0.10

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 (130) hide show
  1. package/README.md +76 -0
  2. package/package.json +106 -0
  3. package/src/api-key-hash.ts +28 -0
  4. package/src/calendar/events.ts +34 -0
  5. package/src/calendar/route.ts +114 -0
  6. package/src/chat/credit-source.ts +1 -0
  7. package/src/chat/google/chat-request-schema.ts +150 -0
  8. package/src/chat/google/default-system-instruction.ts +198 -0
  9. package/src/chat/google/message-file-processing.ts +212 -0
  10. package/src/chat/google/mira-step-preparation.ts +221 -0
  11. package/src/chat/google/new/route.ts +368 -0
  12. package/src/chat/google/route-auth.ts +81 -0
  13. package/src/chat/google/route-chat-resolution.ts +98 -0
  14. package/src/chat/google/route-credits.ts +61 -0
  15. package/src/chat/google/route-message-preparation.ts +331 -0
  16. package/src/chat/google/route-mira-runtime.ts +206 -0
  17. package/src/chat/google/route.ts +632 -0
  18. package/src/chat/google/stream-finish-persistence.ts +722 -0
  19. package/src/chat/google/summary/route.ts +153 -0
  20. package/src/chat/mira-render-ui-policy.ts +540 -0
  21. package/src/chat/mira-system-instruction.ts +484 -0
  22. package/src/chat-sdk/adapters.ts +389 -0
  23. package/src/chat-sdk/registry.ts +197 -0
  24. package/src/chat-sdk.ts +33 -0
  25. package/src/core.ts +3 -0
  26. package/src/credits/cap-output-tokens.ts +90 -0
  27. package/src/credits/check-credits.ts +232 -0
  28. package/src/credits/constants.ts +30 -0
  29. package/src/credits/index.ts +46 -0
  30. package/src/credits/model-mapping.ts +92 -0
  31. package/src/credits/reservations.ts +514 -0
  32. package/src/credits/resolve-plan-model.ts +219 -0
  33. package/src/credits/sync-gateway-models.ts +351 -0
  34. package/src/credits/types.ts +109 -0
  35. package/src/credits/use-ai-credits.ts +3 -0
  36. package/src/embeddings/metered.ts +283 -0
  37. package/src/executions/route.ts +137 -0
  38. package/src/generate/route.ts +411 -0
  39. package/src/hooks.ts +7 -0
  40. package/src/meetings/summary/route.ts +7 -0
  41. package/src/meetings/transcription/route.ts +134 -0
  42. package/src/memory/client.ts +158 -0
  43. package/src/memory/config.ts +38 -0
  44. package/src/memory/index.ts +32 -0
  45. package/src/memory/ingest.ts +51 -0
  46. package/src/memory/middleware.ts +35 -0
  47. package/src/memory/operations.ts +480 -0
  48. package/src/memory/scope.ts +102 -0
  49. package/src/memory/settings.ts +121 -0
  50. package/src/memory/types.ts +101 -0
  51. package/src/memory/workspace.ts +36 -0
  52. package/src/memory.ts +1 -0
  53. package/src/mind/patch.ts +146 -0
  54. package/src/mind/route.ts +687 -0
  55. package/src/mind/tools.ts +1500 -0
  56. package/src/mind/types.ts +20 -0
  57. package/src/object/core.ts +3 -0
  58. package/src/object/flashcards/route.ts +140 -0
  59. package/src/object/quizzes/explanation/route.ts +145 -0
  60. package/src/object/quizzes/route.ts +142 -0
  61. package/src/object/types.ts +187 -0
  62. package/src/object/year-plan/route.ts +196 -0
  63. package/src/react.ts +1 -0
  64. package/src/scheduling/algorithm.ts +791 -0
  65. package/src/scheduling/default.ts +36 -0
  66. package/src/scheduling/duration-optimizer.ts +689 -0
  67. package/src/scheduling/index.ts +79 -0
  68. package/src/scheduling/priority-calculator.ts +187 -0
  69. package/src/scheduling/recurrence-calculator.ts +621 -0
  70. package/src/scheduling/templates.ts +892 -0
  71. package/src/scheduling/types.ts +136 -0
  72. package/src/scheduling/web-adapter.ts +308 -0
  73. package/src/scheduling.ts +6 -0
  74. package/src/supported-actions.ts +1 -0
  75. package/src/supported-providers.ts +6 -0
  76. package/src/tools/context-builder.ts +372 -0
  77. package/src/tools/core.ts +1 -0
  78. package/src/tools/definitions/calendar.ts +106 -0
  79. package/src/tools/definitions/finance.ts +197 -0
  80. package/src/tools/definitions/image.ts +74 -0
  81. package/src/tools/definitions/memory.ts +83 -0
  82. package/src/tools/definitions/meta.ts +154 -0
  83. package/src/tools/definitions/render-ui.ts +81 -0
  84. package/src/tools/definitions/tasks.ts +343 -0
  85. package/src/tools/definitions/time-tracking.ts +381 -0
  86. package/src/tools/definitions/workspace-context.ts +45 -0
  87. package/src/tools/definitions/workspace-user-chat.ts +111 -0
  88. package/src/tools/executors/calendar.ts +371 -0
  89. package/src/tools/executors/chat.ts +15 -0
  90. package/src/tools/executors/finance.ts +638 -0
  91. package/src/tools/executors/helpers/encryption.ts +107 -0
  92. package/src/tools/executors/image.ts +247 -0
  93. package/src/tools/executors/markitdown.ts +684 -0
  94. package/src/tools/executors/memory.ts +277 -0
  95. package/src/tools/executors/parallel-checks.ts +176 -0
  96. package/src/tools/executors/qr.ts +170 -0
  97. package/src/tools/executors/scope-helpers.ts +192 -0
  98. package/src/tools/executors/search.ts +149 -0
  99. package/src/tools/executors/settings.ts +40 -0
  100. package/src/tools/executors/tasks.ts +1087 -0
  101. package/src/tools/executors/theme.ts +23 -0
  102. package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
  103. package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
  104. package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
  105. package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
  106. package/src/tools/executors/timer/timer-helpers.ts +372 -0
  107. package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
  108. package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
  109. package/src/tools/executors/timer/timer-mutations.ts +19 -0
  110. package/src/tools/executors/timer/timer-queries.ts +18 -0
  111. package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
  112. package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
  113. package/src/tools/executors/timer/timer-session-queries.ts +153 -0
  114. package/src/tools/executors/timer/timer-session-updates.ts +200 -0
  115. package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
  116. package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
  117. package/src/tools/executors/timer.ts +22 -0
  118. package/src/tools/executors/user.ts +60 -0
  119. package/src/tools/executors/workspace.ts +135 -0
  120. package/src/tools/json-render-catalog.ts +875 -0
  121. package/src/tools/mira-tool-definitions.ts +55 -0
  122. package/src/tools/mira-tool-dispatcher.ts +265 -0
  123. package/src/tools/mira-tool-metadata.ts +164 -0
  124. package/src/tools/mira-tool-names.ts +95 -0
  125. package/src/tools/mira-tool-render-ui.ts +54 -0
  126. package/src/tools/mira-tool-types.ts +17 -0
  127. package/src/tools/mira-tools.ts +167 -0
  128. package/src/tools/normalize-render-ui-input.ts +321 -0
  129. package/src/tools/workspace-context.ts +233 -0
  130. package/src/types.ts +38 -0
@@ -0,0 +1,621 @@
1
+ /**
2
+ * Recurrence Calculator for Habits
3
+ *
4
+ * This module calculates occurrence dates for habits based on their
5
+ * recurrence patterns (daily, weekly, monthly, yearly, custom).
6
+ */
7
+
8
+ import type { Habit } from '@tuturuuu/types/primitives/Habit';
9
+ import dayjs from 'dayjs';
10
+ import isoWeek from 'dayjs/plugin/isoWeek';
11
+ import utc from 'dayjs/plugin/utc';
12
+
13
+ dayjs.extend(isoWeek);
14
+ dayjs.extend(utc);
15
+
16
+ type ZonedDateTimeParts = {
17
+ year: number;
18
+ month: number;
19
+ day: number;
20
+ hour: number;
21
+ minute: number;
22
+ second?: number;
23
+ };
24
+
25
+ const DEFAULT_LOCALE = 'en-US';
26
+
27
+ function isValidTimeZone(tz: string): boolean {
28
+ if (!tz) return false;
29
+
30
+ try {
31
+ // eslint-disable-next-line no-new
32
+ new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeZone: tz });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function toUtcMinutes(parts: ZonedDateTimeParts): number {
40
+ return Math.floor(
41
+ Date.UTC(
42
+ parts.year,
43
+ parts.month - 1,
44
+ parts.day,
45
+ parts.hour,
46
+ parts.minute,
47
+ parts.second ?? 0
48
+ ) / 60000
49
+ );
50
+ }
51
+
52
+ function getZonedDateTimeParts(date: Date, tz: string): ZonedDateTimeParts {
53
+ const formatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, {
54
+ timeZone: tz,
55
+ year: 'numeric',
56
+ month: '2-digit',
57
+ day: '2-digit',
58
+ hour: '2-digit',
59
+ minute: '2-digit',
60
+ second: '2-digit',
61
+ hour12: false,
62
+ });
63
+
64
+ const parts = formatter.formatToParts(date);
65
+ const get = (type: Intl.DateTimeFormatPartTypes): number => {
66
+ const value = parts.find((part) => part.type === type)?.value;
67
+ return Number.parseInt(value ?? '0', 10);
68
+ };
69
+
70
+ return {
71
+ year: get('year'),
72
+ month: get('month'),
73
+ day: get('day'),
74
+ hour: get('hour'),
75
+ minute: get('minute'),
76
+ second: get('second'),
77
+ };
78
+ }
79
+
80
+ function zonedDateTimeToUtc(parts: ZonedDateTimeParts, tz: string): Date {
81
+ let guessMs = Date.UTC(
82
+ parts.year,
83
+ parts.month - 1,
84
+ parts.day,
85
+ parts.hour,
86
+ parts.minute,
87
+ parts.second ?? 0,
88
+ 0
89
+ );
90
+
91
+ for (let i = 0; i < 3; i++) {
92
+ const actual = getZonedDateTimeParts(new Date(guessMs), tz);
93
+ const diffMinutes = toUtcMinutes(parts) - toUtcMinutes(actual);
94
+ if (diffMinutes === 0) break;
95
+ guessMs += diffMinutes * 60_000;
96
+ }
97
+
98
+ return new Date(guessMs);
99
+ }
100
+
101
+ function getTimezoneSafeYmd(
102
+ value: Date | string,
103
+ timezone?: string | null
104
+ ): { year: number; month: number; day: number } {
105
+ if (typeof value === 'string') {
106
+ const dateOnlyMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
107
+ if (dateOnlyMatch) {
108
+ return {
109
+ year: Number.parseInt(dateOnlyMatch[1] ?? '0', 10),
110
+ month: Number.parseInt(dateOnlyMatch[2] ?? '0', 10),
111
+ day: Number.parseInt(dateOnlyMatch[3] ?? '0', 10),
112
+ };
113
+ }
114
+ }
115
+
116
+ if (timezone && timezone !== 'auto' && isValidTimeZone(timezone)) {
117
+ const zoned = getZonedDateTimeParts(new Date(value), timezone);
118
+ return {
119
+ year: zoned.year,
120
+ month: zoned.month,
121
+ day: zoned.day,
122
+ };
123
+ }
124
+
125
+ const parsed = dayjs(value).startOf('day');
126
+ return {
127
+ year: parsed.year(),
128
+ month: parsed.month() + 1,
129
+ day: parsed.date(),
130
+ };
131
+ }
132
+
133
+ function toDayjsDate(
134
+ value: Date | string,
135
+ timezone?: string | null
136
+ ): dayjs.Dayjs {
137
+ const ymd = getTimezoneSafeYmd(value, timezone);
138
+ return dayjs.utc(Date.UTC(ymd.year, ymd.month - 1, ymd.day, 0, 0, 0, 0));
139
+ }
140
+
141
+ function fromDayjsDate(date: dayjs.Dayjs, timezone?: string | null): Date {
142
+ const year = date.year();
143
+ const month = date.month() + 1;
144
+ const day = date.date();
145
+
146
+ if (timezone && timezone !== 'auto' && isValidTimeZone(timezone)) {
147
+ return zonedDateTimeToUtc(
148
+ { year, month, day, hour: 0, minute: 0, second: 0 },
149
+ timezone
150
+ );
151
+ }
152
+
153
+ return date.toDate();
154
+ }
155
+
156
+ /**
157
+ * Calculate the next N occurrences of a habit from a given date
158
+ */
159
+ export function calculateOccurrences(
160
+ habit: Habit,
161
+ fromDate: Date,
162
+ count: number,
163
+ timezone?: string | null
164
+ ): Date[] {
165
+ const occurrences: Date[] = [];
166
+ const startDate = toDayjsDate(habit.start_date, timezone);
167
+ const endDate = habit.end_date ? toDayjsDate(habit.end_date, timezone) : null;
168
+ let current = toDayjsDate(fromDate, timezone);
169
+
170
+ // Ensure we start from the habit's start date if fromDate is before it
171
+ if (current.isBefore(startDate)) {
172
+ current = startDate.startOf('day');
173
+ }
174
+
175
+ // Find the first occurrence on or after current
176
+ const firstOccurrence = findNextOccurrence(
177
+ habit,
178
+ current.toDate(),
179
+ true,
180
+ timezone
181
+ );
182
+ if (!firstOccurrence) return occurrences;
183
+
184
+ let currentDayjs = toDayjsDate(firstOccurrence, timezone);
185
+
186
+ while (occurrences.length < count) {
187
+ // Check if we've passed the end date
188
+ if (endDate && currentDayjs.isAfter(endDate)) {
189
+ break;
190
+ }
191
+
192
+ occurrences.push(fromDayjsDate(currentDayjs, timezone));
193
+
194
+ // Find next occurrence
195
+ const next = findNextOccurrence(
196
+ habit,
197
+ currentDayjs.toDate(),
198
+ false,
199
+ timezone
200
+ );
201
+ if (!next) break;
202
+
203
+ currentDayjs = toDayjsDate(next, timezone);
204
+ }
205
+
206
+ return occurrences;
207
+ }
208
+
209
+ /**
210
+ * Get all occurrences within a date range
211
+ */
212
+ export function getOccurrencesInRange(
213
+ habit: Habit,
214
+ rangeStart: Date,
215
+ rangeEnd: Date,
216
+ timezone?: string | null
217
+ ): Date[] {
218
+ const occurrences: Date[] = [];
219
+ const startDate = toDayjsDate(habit.start_date, timezone);
220
+ const endDate = habit.end_date ? toDayjsDate(habit.end_date, timezone) : null;
221
+ const rangeEndDayjs = toDayjsDate(rangeEnd, timezone);
222
+
223
+ let current = toDayjsDate(rangeStart, timezone);
224
+
225
+ // Ensure we start from the habit's start date if rangeStart is before it
226
+ if (current.isBefore(startDate)) {
227
+ current = startDate.startOf('day');
228
+ }
229
+
230
+ // Find the first occurrence on or after current
231
+ const first = findNextOccurrence(habit, current.toDate(), true, timezone);
232
+ if (!first) return occurrences;
233
+
234
+ let currentDayjs = toDayjsDate(first, timezone);
235
+
236
+ while (
237
+ currentDayjs.isBefore(rangeEndDayjs) ||
238
+ currentDayjs.isSame(rangeEndDayjs, 'day')
239
+ ) {
240
+ // Check if we've passed the habit's end date
241
+ if (endDate && currentDayjs.isAfter(endDate)) {
242
+ break;
243
+ }
244
+
245
+ occurrences.push(fromDayjsDate(currentDayjs, timezone));
246
+
247
+ // Find next occurrence
248
+ const next = findNextOccurrence(
249
+ habit,
250
+ currentDayjs.toDate(),
251
+ false,
252
+ timezone
253
+ );
254
+ if (!next) break;
255
+
256
+ currentDayjs = toDayjsDate(next, timezone);
257
+
258
+ // Safety check to prevent infinite loops
259
+ if (occurrences.length > 365) break;
260
+ }
261
+
262
+ return occurrences;
263
+ }
264
+
265
+ /**
266
+ * Check if a specific date is an occurrence date for the habit
267
+ */
268
+ export function isOccurrenceDate(
269
+ habit: Habit,
270
+ date: Date,
271
+ timezone?: string | null
272
+ ): boolean {
273
+ const targetDate = toDayjsDate(date, timezone);
274
+ const startDate = toDayjsDate(habit.start_date, timezone);
275
+ const endDate = habit.end_date ? toDayjsDate(habit.end_date, timezone) : null;
276
+
277
+ // Check bounds
278
+ if (targetDate.isBefore(startDate)) return false;
279
+ if (endDate && targetDate.isAfter(endDate)) return false;
280
+
281
+ return matchesRecurrencePattern(habit, targetDate);
282
+ }
283
+
284
+ /**
285
+ * Get the next occurrence after a given date
286
+ * If inclusive is true, the given date is included in the search
287
+ */
288
+ export function getNextOccurrence(
289
+ habit: Habit,
290
+ afterDate: Date,
291
+ timezone?: string | null
292
+ ): Date | null {
293
+ return findNextOccurrence(habit, afterDate, false, timezone);
294
+ }
295
+
296
+ /**
297
+ * Internal function to find the next occurrence
298
+ */
299
+ function findNextOccurrence(
300
+ habit: Habit,
301
+ fromDate: Date,
302
+ inclusive: boolean,
303
+ timezone?: string | null
304
+ ): Date | null {
305
+ const startDate = toDayjsDate(habit.start_date, timezone);
306
+ const endDate = habit.end_date ? toDayjsDate(habit.end_date, timezone) : null;
307
+ let current = toDayjsDate(fromDate, timezone);
308
+
309
+ // Start from the day after if not inclusive
310
+ if (!inclusive) {
311
+ current = current.add(1, 'day');
312
+ }
313
+
314
+ // Ensure we start from the habit's start date
315
+ if (current.isBefore(startDate)) {
316
+ current = startDate;
317
+ }
318
+
319
+ // Check if already past end date
320
+ if (endDate && current.isAfter(endDate)) {
321
+ return null;
322
+ }
323
+
324
+ // For yearly patterns, use optimized search that handles leap years
325
+ if (habit.frequency === 'yearly') {
326
+ return findNextYearlyOccurrence(
327
+ habit,
328
+ startDate,
329
+ current,
330
+ endDate,
331
+ timezone
332
+ );
333
+ }
334
+
335
+ // Search for up to 366 days (handles leap years for non-yearly patterns)
336
+ const maxDaysToSearch = 366;
337
+ for (let i = 0; i < maxDaysToSearch; i++) {
338
+ if (matchesRecurrencePattern(habit, current)) {
339
+ // Check end date
340
+ if (endDate && current.isAfter(endDate)) {
341
+ return null;
342
+ }
343
+ return fromDayjsDate(current, timezone);
344
+ }
345
+ current = current.add(1, 'day');
346
+
347
+ // Check end date during iteration
348
+ if (endDate && current.isAfter(endDate)) {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * Optimized search for yearly patterns
358
+ * Handles leap year dates (Feb 29) correctly by jumping to candidate years
359
+ */
360
+ function findNextYearlyOccurrence(
361
+ habit: Habit,
362
+ startDate: dayjs.Dayjs,
363
+ current: dayjs.Dayjs,
364
+ endDate: dayjs.Dayjs | null,
365
+ timezone?: string | null
366
+ ): Date | null {
367
+ const interval = habit.recurrence_interval;
368
+ const targetMonth = startDate.month();
369
+ const targetDay = startDate.date();
370
+ const isLeapYearDate = targetMonth === 1 && targetDay === 29; // Feb 29
371
+
372
+ // Calculate the first candidate year
373
+ let candidateYear = current.year();
374
+
375
+ // If we're past the target date this year, start from next year
376
+ const thisYearTarget = dayjs.utc(
377
+ Date.UTC(candidateYear, targetMonth, targetDay, 0, 0, 0, 0)
378
+ );
379
+
380
+ if (current.isAfter(thisYearTarget)) {
381
+ candidateYear++;
382
+ }
383
+
384
+ // Align to the interval from start year
385
+ const startYear = startDate.year();
386
+ const yearsDiff = candidateYear - startYear;
387
+ if (yearsDiff < 0) {
388
+ candidateYear = startYear;
389
+ } else if (yearsDiff % interval !== 0) {
390
+ // Round up to next valid interval year
391
+ candidateYear = startYear + Math.ceil(yearsDiff / interval) * interval;
392
+ }
393
+
394
+ // Search up to 100 years (handles leap year dates needing to find next leap year)
395
+ const maxYearsToSearch = 100;
396
+ for (let i = 0; i < maxYearsToSearch; i++) {
397
+ // For leap year dates, check if this year is a leap year
398
+ if (isLeapYearDate) {
399
+ if (!isLeapYear(candidateYear)) {
400
+ candidateYear += interval;
401
+ continue;
402
+ }
403
+ }
404
+
405
+ const candidate = dayjs.utc(
406
+ Date.UTC(candidateYear, targetMonth, targetDay, 0, 0, 0, 0)
407
+ );
408
+
409
+ // Verify the date is valid (handles edge cases)
410
+ if (
411
+ candidate.month() === targetMonth &&
412
+ candidate.date() === targetDay &&
413
+ !candidate.isBefore(current)
414
+ ) {
415
+ if (endDate && candidate.isAfter(endDate)) {
416
+ return null;
417
+ }
418
+ return fromDayjsDate(candidate, timezone);
419
+ }
420
+
421
+ candidateYear += interval;
422
+ }
423
+
424
+ return null;
425
+ }
426
+
427
+ /**
428
+ * Check if a year is a leap year
429
+ */
430
+ function isLeapYear(year: number): boolean {
431
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
432
+ }
433
+
434
+ /**
435
+ * Check if a date matches the habit's recurrence pattern
436
+ */
437
+ function matchesRecurrencePattern(habit: Habit, date: dayjs.Dayjs): boolean {
438
+ const startDate = dayjs(habit.start_date).startOf('day');
439
+ const { frequency, recurrence_interval: interval } = habit;
440
+
441
+ switch (frequency) {
442
+ case 'daily':
443
+ return matchesDailyPattern(startDate, date, interval);
444
+
445
+ case 'weekly':
446
+ return matchesWeeklyPattern(habit, startDate, date, interval);
447
+
448
+ case 'monthly':
449
+ return matchesMonthlyPattern(habit, startDate, date, interval);
450
+
451
+ case 'yearly':
452
+ return matchesYearlyPattern(startDate, date, interval);
453
+
454
+ case 'custom':
455
+ // Custom is treated as "every N days" from start
456
+ return matchesDailyPattern(startDate, date, interval);
457
+
458
+ default:
459
+ return false;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Check if date matches daily pattern (every N days from start)
465
+ */
466
+ function matchesDailyPattern(
467
+ startDate: dayjs.Dayjs,
468
+ date: dayjs.Dayjs,
469
+ interval: number
470
+ ): boolean {
471
+ const daysDiff = date.diff(startDate, 'day');
472
+ return daysDiff >= 0 && daysDiff % interval === 0;
473
+ }
474
+
475
+ /**
476
+ * Check if date matches weekly pattern
477
+ */
478
+ function matchesWeeklyPattern(
479
+ habit: Habit,
480
+ startDate: dayjs.Dayjs,
481
+ date: dayjs.Dayjs,
482
+ interval: number
483
+ ): boolean {
484
+ const { days_of_week } = habit;
485
+
486
+ // If no specific days are set, use the same day as start date
487
+ const targetDays =
488
+ days_of_week && days_of_week.length > 0 ? days_of_week : [startDate.day()];
489
+
490
+ // Check if the day of week matches
491
+ const dayOfWeek = date.day(); // 0 = Sunday, 6 = Saturday
492
+ if (!targetDays.includes(dayOfWeek)) {
493
+ return false;
494
+ }
495
+
496
+ // Check if it's the right week (every N weeks)
497
+ if (interval === 1) return true;
498
+
499
+ // Calculate week difference from start
500
+ const startWeek = startDate.startOf('week');
501
+ const dateWeek = date.startOf('week');
502
+ const weeksDiff = dateWeek.diff(startWeek, 'week');
503
+
504
+ return weeksDiff >= 0 && weeksDiff % interval === 0;
505
+ }
506
+
507
+ /**
508
+ * Check if date matches monthly pattern
509
+ */
510
+ function matchesMonthlyPattern(
511
+ habit: Habit,
512
+ startDate: dayjs.Dayjs,
513
+ date: dayjs.Dayjs,
514
+ interval: number
515
+ ): boolean {
516
+ const { monthly_type, day_of_month, week_of_month, day_of_week_monthly } =
517
+ habit;
518
+
519
+ // Check month interval
520
+ const monthsDiff = getMonthsDiff(startDate, date);
521
+ if (monthsDiff < 0 || monthsDiff % interval !== 0) {
522
+ return false;
523
+ }
524
+
525
+ if (monthly_type === 'day_of_month') {
526
+ // Match specific day of month (e.g., 15th)
527
+ const targetDay = day_of_month ?? startDate.date();
528
+ return matchesDayOfMonth(date, targetDay);
529
+ } else if (monthly_type === 'day_of_week') {
530
+ // Match nth weekday of month (e.g., 2nd Tuesday)
531
+ const targetWeek = week_of_month ?? 1;
532
+ const targetDayOfWeek = day_of_week_monthly ?? startDate.day();
533
+ return matchesNthWeekday(date, targetWeek, targetDayOfWeek);
534
+ }
535
+
536
+ // Default: same day as start date
537
+ return date.date() === startDate.date();
538
+ }
539
+
540
+ /**
541
+ * Check if date matches yearly pattern
542
+ */
543
+ function matchesYearlyPattern(
544
+ startDate: dayjs.Dayjs,
545
+ date: dayjs.Dayjs,
546
+ interval: number
547
+ ): boolean {
548
+ // Must be same month and day
549
+ if (date.month() !== startDate.month() || date.date() !== startDate.date()) {
550
+ return false;
551
+ }
552
+
553
+ // Check year interval
554
+ const yearsDiff = date.year() - startDate.year();
555
+ return yearsDiff >= 0 && yearsDiff % interval === 0;
556
+ }
557
+
558
+ /**
559
+ * Get the number of months between two dates
560
+ */
561
+ function getMonthsDiff(start: dayjs.Dayjs, end: dayjs.Dayjs): number {
562
+ return (end.year() - start.year()) * 12 + (end.month() - start.month());
563
+ }
564
+
565
+ /**
566
+ * Check if a date matches a specific day of month
567
+ * Handles edge cases like Feb 30 -> Feb 28/29
568
+ */
569
+ function matchesDayOfMonth(date: dayjs.Dayjs, targetDay: number): boolean {
570
+ const lastDayOfMonth = date.endOf('month').date();
571
+ const actualTargetDay = Math.min(targetDay, lastDayOfMonth);
572
+ return date.date() === actualTargetDay;
573
+ }
574
+
575
+ /**
576
+ * Check if a date is the nth weekday of its month
577
+ * @param week - 1-4 for first through fourth, 5 for "last"
578
+ * @param dayOfWeek - 0 (Sunday) through 6 (Saturday)
579
+ */
580
+ function matchesNthWeekday(
581
+ date: dayjs.Dayjs,
582
+ week: number,
583
+ dayOfWeek: number
584
+ ): boolean {
585
+ // Check if it's the right day of week
586
+ if (date.day() !== dayOfWeek) {
587
+ return false;
588
+ }
589
+
590
+ if (week === 5) {
591
+ // "Last" weekday of month
592
+ // Check if adding 7 days would go to next month
593
+ return date.add(7, 'day').month() !== date.month();
594
+ }
595
+
596
+ // Check if it's the nth occurrence
597
+ const dayOfMonth = date.date();
598
+ const weekOfMonth = Math.ceil(dayOfMonth / 7);
599
+ return weekOfMonth === week;
600
+ }
601
+
602
+ /**
603
+ * Get a human-readable description of when the next occurrence is
604
+ */
605
+ export function getNextOccurrenceDescription(
606
+ habit: Habit,
607
+ fromDate: Date = new Date(),
608
+ timezone?: string | null
609
+ ): string {
610
+ const next = getNextOccurrence(habit, fromDate, timezone);
611
+ if (!next) return 'No more occurrences';
612
+
613
+ const nextDayjs = toDayjsDate(next, timezone);
614
+ const today = toDayjsDate(fromDate, timezone);
615
+ const diff = nextDayjs.diff(today, 'day');
616
+
617
+ if (diff === 0) return 'Today';
618
+ if (diff === 1) return 'Tomorrow';
619
+ if (diff < 7) return nextDayjs.format('dddd'); // Day name
620
+ return nextDayjs.format('MMM D'); // e.g., "Jan 15"
621
+ }