refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле
landing: - site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE) - form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset - StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы - LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq - Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались) - одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body api: - changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL - errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений - backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour - без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами - testing.ts: validLead, addLead, count, leadIds вместо копий в тестах deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
This commit is contained in:
+28
-23
@@ -19,12 +19,13 @@ import Database from 'better-sqlite3';
|
||||
import { openDb, type Db, type DbHolder } from './db/index.js';
|
||||
import { MIGRATIONS } from './db/migrations.js';
|
||||
import type { TelegramClient } from './telegram/client.js';
|
||||
import { shortError, type SystemReporter } from './telegram/system.js';
|
||||
import { errorMessage, shortError, type SystemReporter } from './telegram/system.js';
|
||||
|
||||
export const TIME_ZONE = 'Europe/Minsk';
|
||||
export const KEEP_DAILY = 14;
|
||||
export const KEEP_PRE_RESTORE_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DAILY_HOUR = 3;
|
||||
|
||||
export type BackupKind = 'daily' | 'pre-restore';
|
||||
export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number };
|
||||
@@ -37,17 +38,19 @@ export function backupDirFor(dbPath: string): string {
|
||||
|
||||
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number };
|
||||
|
||||
const MINSK_PARTS = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
|
||||
export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
export function localParts(date: Date): LocalParts {
|
||||
const parts = MINSK_PARTS.formatToParts(date);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)!.value);
|
||||
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
|
||||
}
|
||||
@@ -67,9 +70,9 @@ export function backupFileName(date: Date, kind: BackupKind): string {
|
||||
}
|
||||
|
||||
/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */
|
||||
function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date {
|
||||
function fromLocal(p: LocalParts): Date {
|
||||
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
||||
const shown = localParts(new Date(guess), timeZone);
|
||||
const shown = localParts(new Date(guess));
|
||||
const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess;
|
||||
return new Date(guess - offset);
|
||||
}
|
||||
@@ -85,12 +88,12 @@ function parseName(file: string): { kind: BackupKind; at: Date } | null {
|
||||
}
|
||||
|
||||
/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */
|
||||
export function nextDailyRun(now: Date, hour = 3): Date {
|
||||
export function nextDailyRun(now: Date): Date {
|
||||
const today = localParts(now);
|
||||
let run = fromLocal({ ...today, hour, minute: 0 });
|
||||
let run = fromLocal({ ...today, hour: DAILY_HOUR, minute: 0 });
|
||||
if (run.getTime() <= now.getTime()) {
|
||||
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
|
||||
run = fromLocal({ ...tomorrow, hour, minute: 0 });
|
||||
run = fromLocal({ ...tomorrow, hour: DAILY_HOUR, minute: 0 });
|
||||
}
|
||||
return run;
|
||||
}
|
||||
@@ -125,7 +128,7 @@ export function rotateBackups(dir: string, now: Date): string[] {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function countLeads(db: Db): number {
|
||||
export function countLeads(db: Db): number {
|
||||
return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
@@ -199,8 +202,7 @@ export async function inspectBackup(source: string | Uint8Array, dir: string): P
|
||||
}
|
||||
const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get();
|
||||
if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.');
|
||||
const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return { path, userVersion, leads };
|
||||
return { path, userVersion, leads: leadStamps(copy) };
|
||||
} finally {
|
||||
copy.close();
|
||||
}
|
||||
@@ -220,8 +222,11 @@ export function discardInspected(inspected: InspectedBackup): void {
|
||||
/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */
|
||||
export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] {
|
||||
const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`));
|
||||
const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
|
||||
return leadStamps(db).filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
|
||||
}
|
||||
|
||||
function leadStamps(db: Db): InspectedBackup['leads'] {
|
||||
return db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
}
|
||||
|
||||
function removeSidecars(path: string) {
|
||||
@@ -347,7 +352,7 @@ export async function runBackupAndReport({
|
||||
}): Promise<BackupResult | undefined> {
|
||||
let result: BackupResult;
|
||||
try {
|
||||
result = await createBackup(holder.db, dir, now, 'daily');
|
||||
result = await createBackup(holder.db, dir, now);
|
||||
rotateBackups(dir, now);
|
||||
} catch (err) {
|
||||
await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`);
|
||||
@@ -363,7 +368,7 @@ export async function runBackupAndReport({
|
||||
caption: summary,
|
||||
});
|
||||
} catch (err) {
|
||||
delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`;
|
||||
delivery = `, но в личку не отправлен: ${errorMessage(err)}`;
|
||||
}
|
||||
}
|
||||
await system.report('backup', `${summary} ✅${delivery}`);
|
||||
|
||||
Reference in New Issue
Block a user