feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Düzenli işlem tekrarı (Bölüm 6.7). Saf fonksiyonlar: tarihler "YYYY-MM-DD" (İstanbul günü).
|
||||
* Aylık/yıllık tekrarda gün, başlangıç tarihinin gününden alınır ve ayda yoksa ayın son günü
|
||||
* kullanılır (31 Ocak → 28/29 Şubat → 31 Mart). "Ayın son günü" modu her zaman son günü verir.
|
||||
*/
|
||||
|
||||
export const FREQUENCIES = ["daily", "weekly", "monthly", "yearly"] as const;
|
||||
export type Frequency = (typeof FREQUENCIES)[number];
|
||||
export const MONTHLY_MODES = ["day", "last"] as const;
|
||||
export type MonthlyMode = (typeof MONTHLY_MODES)[number];
|
||||
|
||||
/** Planlı kayıtların üretileceği ufuk (ay). */
|
||||
export const HORIZON_MONTHS = 12;
|
||||
export const MAX_OCCURRENCES = 600;
|
||||
|
||||
export type Recurrence = {
|
||||
frequency: Frequency;
|
||||
/** Her N gün/hafta/ay/yılda bir (1–12). */
|
||||
interval: number;
|
||||
monthlyMode: MonthlyMode;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
/** Toplam tekrar sayısı (ör. 12) — endDate ile birlikte verilirse hangisi önce gelirse. */
|
||||
count: number | null;
|
||||
};
|
||||
|
||||
const parse = (ymd: string) => {
|
||||
const [y, m, d] = ymd.split("-").map(Number) as [number, number, number];
|
||||
return { y, m, d };
|
||||
};
|
||||
const fmt = (y: number, m: number, d: number) =>
|
||||
`${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
|
||||
export function daysInMonth(y: number, m: number): number {
|
||||
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
export function addDays(ymd: string, days: number): string {
|
||||
const { y, m, d } = parse(ymd);
|
||||
const t = new Date(Date.UTC(y, m - 1, d + days));
|
||||
return fmt(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate());
|
||||
}
|
||||
|
||||
/** Ay ekler; gün ayda yoksa ayın son günü. `day` verilirse o gün hedeflenir. */
|
||||
export function addMonths(ymd: string, months: number, day?: number | "last"): string {
|
||||
const { y, m, d } = parse(ymd);
|
||||
const index = y * 12 + (m - 1) + months;
|
||||
const ny = Math.floor(index / 12);
|
||||
const nm = (index % 12) + 1;
|
||||
const last = daysInMonth(ny, nm);
|
||||
const target = day === "last" ? last : Math.min(day ?? d, last);
|
||||
return fmt(ny, nm, target);
|
||||
}
|
||||
|
||||
/** index. tekrarın tarihi (0 = başlangıç; "son gün" modunda başlangıç ayının son günü). */
|
||||
export function occurrenceDate(r: Recurrence, index: number): string {
|
||||
const n = index * r.interval;
|
||||
switch (r.frequency) {
|
||||
case "daily":
|
||||
return addDays(r.startDate, n);
|
||||
case "weekly":
|
||||
return addDays(r.startDate, n * 7);
|
||||
case "monthly":
|
||||
return addMonths(r.startDate, n, r.monthlyMode === "last" ? "last" : parse(r.startDate).d);
|
||||
case "yearly":
|
||||
return addMonths(r.startDate, n * 12, parse(r.startDate).d);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ufuk tarihine (dahil) kadar, `fromIndex`ten başlayarak tekrarlar. */
|
||||
export function listOccurrences(
|
||||
r: Recurrence,
|
||||
until: string,
|
||||
fromIndex = 0,
|
||||
): { index: number; date: string }[] {
|
||||
const out: { index: number; date: string }[] = [];
|
||||
for (let i = fromIndex; i < MAX_OCCURRENCES; i++) {
|
||||
if (r.count !== null && i >= r.count) break;
|
||||
const date = occurrenceDate(r, i);
|
||||
if (r.endDate && date > r.endDate) break;
|
||||
if (date > until) break;
|
||||
out.push({ index: i, date });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Seri bitti mi (bir sonraki tekrar yok)? */
|
||||
export function isFinished(r: Recurrence, nextIndex: number): boolean {
|
||||
if (r.count !== null && nextIndex >= r.count) return true;
|
||||
return Boolean(r.endDate && occurrenceDate(r, nextIndex) > r.endDate);
|
||||
}
|
||||
|
||||
export function horizonDate(today: string): string {
|
||||
return addMonths(today, HORIZON_MONTHS);
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
"Ocak",
|
||||
"Şubat",
|
||||
"Mart",
|
||||
"Nisan",
|
||||
"Mayıs",
|
||||
"Haziran",
|
||||
"Temmuz",
|
||||
"Ağustos",
|
||||
"Eylül",
|
||||
"Ekim",
|
||||
"Kasım",
|
||||
"Aralık",
|
||||
];
|
||||
const WEEKDAYS = ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"];
|
||||
|
||||
/** "Her ay 3. gün", "2 haftada bir Pazartesi", "Her ayın son günü", "Her yıl 15 Mart". */
|
||||
export function describeRecurrence(
|
||||
r: Pick<Recurrence, "frequency" | "interval" | "monthlyMode" | "startDate">,
|
||||
): string {
|
||||
const { y, m, d } = parse(r.startDate);
|
||||
const every = (unit: string) =>
|
||||
r.interval === 1 ? `Her ${unit}` : `${r.interval} ${unit}da bir`;
|
||||
switch (r.frequency) {
|
||||
case "daily":
|
||||
return r.interval === 1 ? "Her gün" : `${r.interval} günde bir`;
|
||||
case "weekly": {
|
||||
const wd = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!;
|
||||
return r.interval === 1 ? `Her hafta ${wd}` : `${r.interval} haftada bir ${wd}`;
|
||||
}
|
||||
case "monthly":
|
||||
if (r.monthlyMode === "last")
|
||||
return r.interval === 1 ? "Her ayın son günü" : `${r.interval} ayda bir, ayın son günü`;
|
||||
return r.interval === 1 ? `Her ay ${d}. gün` : `${every("ay")}, ${d}. gün`;
|
||||
case "yearly":
|
||||
return r.interval === 1
|
||||
? `Her yıl ${d} ${MONTHS[m - 1]}`
|
||||
: `${r.interval} yılda bir, ${d} ${MONTHS[m - 1]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user