@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.
- package/README.md +76 -0
- package/package.json +106 -0
- package/src/api-key-hash.ts +28 -0
- package/src/calendar/events.ts +34 -0
- package/src/calendar/route.ts +114 -0
- package/src/chat/credit-source.ts +1 -0
- package/src/chat/google/chat-request-schema.ts +150 -0
- package/src/chat/google/default-system-instruction.ts +198 -0
- package/src/chat/google/message-file-processing.ts +212 -0
- package/src/chat/google/mira-step-preparation.ts +221 -0
- package/src/chat/google/new/route.ts +368 -0
- package/src/chat/google/route-auth.ts +81 -0
- package/src/chat/google/route-chat-resolution.ts +98 -0
- package/src/chat/google/route-credits.ts +61 -0
- package/src/chat/google/route-message-preparation.ts +331 -0
- package/src/chat/google/route-mira-runtime.ts +206 -0
- package/src/chat/google/route.ts +632 -0
- package/src/chat/google/stream-finish-persistence.ts +722 -0
- package/src/chat/google/summary/route.ts +153 -0
- package/src/chat/mira-render-ui-policy.ts +540 -0
- package/src/chat/mira-system-instruction.ts +484 -0
- package/src/chat-sdk/adapters.ts +389 -0
- package/src/chat-sdk/registry.ts +197 -0
- package/src/chat-sdk.ts +33 -0
- package/src/core.ts +3 -0
- package/src/credits/cap-output-tokens.ts +90 -0
- package/src/credits/check-credits.ts +232 -0
- package/src/credits/constants.ts +30 -0
- package/src/credits/index.ts +46 -0
- package/src/credits/model-mapping.ts +92 -0
- package/src/credits/reservations.ts +514 -0
- package/src/credits/resolve-plan-model.ts +219 -0
- package/src/credits/sync-gateway-models.ts +351 -0
- package/src/credits/types.ts +109 -0
- package/src/credits/use-ai-credits.ts +3 -0
- package/src/embeddings/metered.ts +283 -0
- package/src/executions/route.ts +137 -0
- package/src/generate/route.ts +411 -0
- package/src/hooks.ts +7 -0
- package/src/meetings/summary/route.ts +7 -0
- package/src/meetings/transcription/route.ts +134 -0
- package/src/memory/client.ts +158 -0
- package/src/memory/config.ts +38 -0
- package/src/memory/index.ts +32 -0
- package/src/memory/ingest.ts +51 -0
- package/src/memory/middleware.ts +35 -0
- package/src/memory/operations.ts +480 -0
- package/src/memory/scope.ts +102 -0
- package/src/memory/settings.ts +121 -0
- package/src/memory/types.ts +101 -0
- package/src/memory/workspace.ts +36 -0
- package/src/memory.ts +1 -0
- package/src/mind/patch.ts +146 -0
- package/src/mind/route.ts +687 -0
- package/src/mind/tools.ts +1500 -0
- package/src/mind/types.ts +20 -0
- package/src/object/core.ts +3 -0
- package/src/object/flashcards/route.ts +140 -0
- package/src/object/quizzes/explanation/route.ts +145 -0
- package/src/object/quizzes/route.ts +142 -0
- package/src/object/types.ts +187 -0
- package/src/object/year-plan/route.ts +196 -0
- package/src/react.ts +1 -0
- package/src/scheduling/algorithm.ts +791 -0
- package/src/scheduling/default.ts +36 -0
- package/src/scheduling/duration-optimizer.ts +689 -0
- package/src/scheduling/index.ts +79 -0
- package/src/scheduling/priority-calculator.ts +187 -0
- package/src/scheduling/recurrence-calculator.ts +621 -0
- package/src/scheduling/templates.ts +892 -0
- package/src/scheduling/types.ts +136 -0
- package/src/scheduling/web-adapter.ts +308 -0
- package/src/scheduling.ts +6 -0
- package/src/supported-actions.ts +1 -0
- package/src/supported-providers.ts +6 -0
- package/src/tools/context-builder.ts +372 -0
- package/src/tools/core.ts +1 -0
- package/src/tools/definitions/calendar.ts +106 -0
- package/src/tools/definitions/finance.ts +197 -0
- package/src/tools/definitions/image.ts +74 -0
- package/src/tools/definitions/memory.ts +83 -0
- package/src/tools/definitions/meta.ts +154 -0
- package/src/tools/definitions/render-ui.ts +81 -0
- package/src/tools/definitions/tasks.ts +343 -0
- package/src/tools/definitions/time-tracking.ts +381 -0
- package/src/tools/definitions/workspace-context.ts +45 -0
- package/src/tools/definitions/workspace-user-chat.ts +111 -0
- package/src/tools/executors/calendar.ts +371 -0
- package/src/tools/executors/chat.ts +15 -0
- package/src/tools/executors/finance.ts +638 -0
- package/src/tools/executors/helpers/encryption.ts +107 -0
- package/src/tools/executors/image.ts +247 -0
- package/src/tools/executors/markitdown.ts +684 -0
- package/src/tools/executors/memory.ts +277 -0
- package/src/tools/executors/parallel-checks.ts +176 -0
- package/src/tools/executors/qr.ts +170 -0
- package/src/tools/executors/scope-helpers.ts +192 -0
- package/src/tools/executors/search.ts +149 -0
- package/src/tools/executors/settings.ts +40 -0
- package/src/tools/executors/tasks.ts +1087 -0
- package/src/tools/executors/theme.ts +23 -0
- package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
- package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
- package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
- package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
- package/src/tools/executors/timer/timer-helpers.ts +372 -0
- package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
- package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
- package/src/tools/executors/timer/timer-mutations.ts +19 -0
- package/src/tools/executors/timer/timer-queries.ts +18 -0
- package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
- package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
- package/src/tools/executors/timer/timer-session-queries.ts +153 -0
- package/src/tools/executors/timer/timer-session-updates.ts +200 -0
- package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
- package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
- package/src/tools/executors/timer.ts +22 -0
- package/src/tools/executors/user.ts +60 -0
- package/src/tools/executors/workspace.ts +135 -0
- package/src/tools/json-render-catalog.ts +875 -0
- package/src/tools/mira-tool-definitions.ts +55 -0
- package/src/tools/mira-tool-dispatcher.ts +265 -0
- package/src/tools/mira-tool-metadata.ts +164 -0
- package/src/tools/mira-tool-names.ts +95 -0
- package/src/tools/mira-tool-render-ui.ts +54 -0
- package/src/tools/mira-tool-types.ts +17 -0
- package/src/tools/mira-tools.ts +167 -0
- package/src/tools/normalize-render-ui-input.ts +321 -0
- package/src/tools/workspace-context.ts +233 -0
- 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
|
+
}
|