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:
2026-11-05 10:55:05 +03:00
parent eaafe5dd9b
commit 04a17d2775
13 changed files with 1493 additions and 7 deletions
+136
View File
@@ -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]}`;
}
}