// ov2-data.jsx — synthetic data + i18n for SEOVILLAGE Overview v2

const seed = (s) => () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };

const series30 = (sd, base, variance) => {
  const r = seed(sd);
  return Array.from({ length: 30 }, (_, i) => Math.round(base * (1 + (r() - 0.5) * variance) * (1 + i * 0.005)));
};

const seriesN = (n, sd, base, variance, scale = 1) => {
  const r = seed(sd);
  return Array.from({ length: n }, (_, i) => Math.round(base * scale * (1 + (r() - 0.5) * variance) * (1 + i * 0.004)));
};

const ov2Num = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0;
const ov2Money = (value) => `€${Math.round(value).toLocaleString('en-US')}`;
const ov2Pct = (value) => `${Number(value).toFixed(value % 1 ? 1 : 0)}%`;
const ov2X = (value) => `${Number(value).toFixed(1)}x`;
const ov2PeriodDays = (period) => {
  if (period === '7d') return 7;
  if (period === '90d') return 90;
  if (period === 'ytd') {
    const now = new Date();
    const start = new Date(now.getFullYear(), 0, 1, 12);
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
    return Math.max(1, Math.round((today - start) / 86400000) + 1);
  }
  return 30;
};
const ov2PeriodScale = (period, sd = 101) => {
  const base = ov2PeriodDays(period) / 30;
  if (period === '30d') return 1;
  const r = seed(sd)();
  return base * (0.9 + r * 0.2);
};
const ov2AvgJitter = (value, period, sd = 151) => {
  if (period === '30d') return value;
  const r = seed(sd)();
  return value * (0.975 + r * 0.05);
};
const ov2CompareVolumeDelta = (compare, isAlert) => {
  const map = { prev: 12, ly: 28, avg90: 5 };
  const value = map[compare] || map.prev;
  return isAlert ? -Math.max(4, Math.round(value * 0.65)) : value;
};
const ov2CompareRateDelta = (compare, isAlert) => {
  const map = { prev: 2, ly: 5, avg90: 1 };
  const value = map[compare] || map.prev;
  return isAlert ? -Math.max(1, Math.round(value * 0.8)) : value;
};
const ov2ScaleMoneyInText = (text, scale) => String(text || '').replace(/€([\d,]+)/g, (_, n) => ov2Money(ov2Num(n) * scale));

const OV2_CABINS = ['Bali', 'Cyprus', 'Portofino', 'Latvia', 'Maldives'];
const OV2_TODAY = (() => {
  const d = new Date();
  d.setHours(12, 0, 0, 0);
  return d;
})();
const ov2Pad = (n) => String(n).padStart(2, '0');
const ov2IsoDate = (d) => `${d.getFullYear()}-${ov2Pad(d.getMonth() + 1)}-${ov2Pad(d.getDate())}`;
const ov2DateOffset = (offset) => {
  const d = new Date(OV2_TODAY);
  d.setDate(d.getDate() + offset);
  return ov2IsoDate(d);
};
const ov2Booking = (id, status, cabin, guestName, guestNameMasked, country, arrivalOffset, nights, bookingOffset, guests, channel, discovery, discoveryConfidence, revenue, originalRevenue, repeatVisitCount, notes) => ({
  id,
  status,
  cabin,
  guestName,
  guestNameMasked,
  country,
  arrival: ov2DateOffset(arrivalOffset),
  departure: ov2DateOffset(arrivalOffset + nights),
  bookingDate: ov2DateOffset(bookingOffset),
  guests,
  channel,
  discovery,
  discoveryConfidence,
  revenue,
  originalRevenue,
  repeatVisitCount,
  notes,
});
const OV2_BOOKINGS = [
  ov2Booking('B-2841', 'checked_out', 'Bali', 'Ozols Martins', 'O. M.', { code: 'LV', name: 'Латвия', flag: '🇱🇻' }, -13, 2, -31, { adults: 2, children: 0 }, 'Direct', 'Instagram', 'manual', 420, null, 2, 'Asked for sauna before arrival'),
  ov2Booking('B-2842', 'checked_out', 'Cyprus', 'Jankauskas Tomas', 'J. T.', { code: 'LT', name: 'Литва', flag: '🇱🇹' }, -12, 1, -20, { adults: 2, children: 1 }, 'Booking.com', '', 'unknown', 260, null, 1, null),
  ov2Booking('B-2843', 'checked_out', 'Portofino', 'Kask Anna', 'K. A.', { code: 'EE', name: 'Эстония', flag: '🇪🇪' }, -11, 3, -24, { adults: 2, children: 0 }, 'WhatsApp', 'Word of mouth', 'manual', 690, null, 3, 'Anniversary couple, send champagne'),
  ov2Booking('B-2844', 'cancelled', 'Latvia', 'Schmidt Laura', 'S. L.', { code: 'DE', name: 'Германия', flag: '🇩🇪' }, -9, 2, -18, { adults: 2, children: 0 }, 'Expedia', '', null, 0, 440, 1, 'Cancelled due to flight change'),
  ov2Booking('B-2845', 'checked_out', 'Maldives', 'Korhonen Mika', 'K. M.', { code: 'FI', name: 'Финляндия', flag: '🇫🇮' }, -8, 2, -26, { adults: 2, children: 2 }, 'Booking.com', 'Google Ads', 'ga4', 560, null, 1, null),
  ov2Booking('B-2846', 'checked_out', 'Bali', 'Ivanova Marina', 'I. M.', { code: 'LV', name: 'Латвия', flag: '🇱🇻' }, -6, 1, -10, { adults: 1, children: 0 }, 'Phone', '', 'unknown', 190, null, 2, null),
  ov2Booking('B-2847', 'checked_out', 'Cyprus', 'Petraitis Egle', 'P. E.', { code: 'LT', name: 'Литва', flag: '🇱🇹' }, -5, 2, -16, { adults: 2, children: 0 }, 'Direct', 'Booking.com', 'manual', 480, null, 1, 'Late check-out requested'),
  ov2Booking('B-2848', 'checked_out', 'Portofino', 'Sokolov Andrei', 'S. A.', { code: 'RU', name: 'Россия', flag: '🇷🇺' }, -4, 2, -19, { adults: 2, children: 1 }, 'Booking.com', '', null, 520, null, 1, null),
  ov2Booking('B-2849', 'checked_out', 'Latvia', 'Muller Felix', 'M. F.', { code: 'DE', name: 'Германия', flag: '🇩🇪' }, -3, 1, -12, { adults: 2, children: 0 }, 'Instagram DM', '', 'unknown', 230, null, 1, null),
  ov2Booking('B-2850', 'checked_out', 'Maldives', 'Laine Sofia', 'L. S.', { code: 'FI', name: 'Финляндия', flag: '🇫🇮' }, -2, 2, -11, { adults: 2, children: 0 }, 'WhatsApp', '', 'unknown', 470, null, 4, 'Allergy: nuts in SPA'),
  ov2Booking('B-2851', 'in_house', 'Bali', 'Иванов Александр', 'И. А.', { code: 'LV', name: 'Латвия', flag: '🇱🇻' }, -1, 3, -8, { adults: 2, children: 0 }, 'Direct', 'Word of mouth', 'manual', 620, null, 3, 'Late check-out requested'),
  ov2Booking('B-2852', 'in_house', 'Cyprus', 'Raudsepp Liis', 'R. L.', { code: 'EE', name: 'Эстония', flag: '🇪🇪' }, 0, 2, -6, { adults: 2, children: 1 }, 'Booking.com', '', null, 540, null, 1, 'Baby cot needed'),
  ov2Booking('B-2853', 'in_house', 'Portofino', 'Berg Henrik', 'B. H.', { code: 'NO', name: 'Норвегия', flag: '🇳🇴' }, 0, 1, -4, { adults: 1, children: 0 }, 'Phone', '', 'unknown', 280, null, 2, null),
  ov2Booking('B-2854', 'confirmed', 'Latvia', 'Kalnina Elina', 'K. E.', { code: 'LV', name: 'Латвия', flag: '🇱🇻' }, 1, 2, -9, { adults: 2, children: 0 }, 'Instagram DM', 'Google search', 'ga4', 460, null, 1, 'Prefers quiet cabin'),
  ov2Booking('B-2855', 'confirmed', 'Maldives', 'Virtanen Aino', 'V. A.', { code: 'FI', name: 'Финляндия', flag: '🇫🇮' }, 1, 2, -14, { adults: 2, children: 2 }, 'Booking.com', '', null, 590, null, 1, null),
  ov2Booking('B-2856', 'confirmed', 'Bali', 'Anderson Mark', 'A. M.', { code: 'US', name: 'США', flag: '🇺🇸' }, 1, 3, -11, { adults: 2, children: 0 }, 'Direct', 'Instagram', 'manual', 840, null, 2, 'Anniversary couple, send champagne'),
  ov2Booking('B-2857', 'confirmed', 'Cyprus', 'Nowak Marta', 'N. M.', { code: 'PL', name: 'Польша', flag: '🇵🇱' }, 4, 1, -3, { adults: 2, children: 0 }, 'Expedia', 'Booking.com', 'manual', 240, null, 1, null),
  ov2Booking('B-2858', 'confirmed', 'Portofino', 'Vasiljev Dmitri', 'V. D.', { code: 'EE', name: 'Эстония', flag: '🇪🇪' }, 5, 2, -7, { adults: 2, children: 1 }, 'WhatsApp', '', 'unknown', 520, null, 1, 'SPA package requested'),
  ov2Booking('B-2859', 'confirmed', 'Latvia', 'Hoffmann Dieter', 'H. D.', { code: 'DE', name: 'Германия', flag: '🇩🇪' }, 7, 3, -21, { adults: 2, children: 0 }, 'Direct', 'Returning guest', 'manual', 780, null, 5, 'Same wine as last stay'),
  ov2Booking('B-2860', 'confirmed', 'Maldives', 'Tamm Kristjan', 'T. K.', { code: 'EE', name: 'Эстония', flag: '🇪🇪' }, 8, 2, -13, { adults: 2, children: 2 }, 'Instagram DM', 'Google Ads', 'ga4', 610, null, 1, null),
  ov2Booking('B-2861', 'cancelled', 'Bali', 'Lehtonen Oskari', 'L. O.', { code: 'FI', name: 'Финляндия', flag: '🇫🇮' }, 9, 1, -2, { adults: 2, children: 0 }, 'Booking.com', '', null, 0, 280, 1, 'Card authorization failed'),
  ov2Booking('B-2862', 'confirmed', 'Cyprus', 'Berzina Liene', 'B. L.', { code: 'LV', name: 'Латвия', flag: '🇱🇻' }, 10, 2, -5, { adults: 2, children: 0 }, 'WhatsApp', 'Word of mouth', 'manual', 430, null, 2, null),
  ov2Booking('B-2863', 'no_show', 'Portofino', 'Petrov Kirill', 'P. K.', { code: 'RU', name: 'Россия', flag: '🇷🇺' }, 11, 1, -1, { adults: 1, children: 0 }, 'Phone', '', 'unknown', 0, 210, 1, 'No response after confirmation call'),
  ov2Booking('B-2864', 'confirmed', 'Latvia', 'Saar Maarika', 'S. M.', { code: 'EE', name: 'Эстония', flag: '🇪🇪' }, 13, 2, -6, { adults: 2, children: 1 }, 'Direct', 'Instagram', 'manual', 520, null, 1, null),
  ov2Booking('B-2865', 'confirmed', 'Maldives', 'Klein Anna', 'K. A.', { code: 'DE', name: 'Германия', flag: '🇩🇪' }, 14, 3, -17, { adults: 2, children: 0 }, 'Expedia', 'Google search', 'ga4', 890, null, 3, 'Vegetarian breakfast'),
];

const OV2_PULSE = {
  todayIso: '2026-04-14',
  todayStr: {
    ru: '14 апреля 2026',
    en: 'April 14, 2026',
  },
  cabins: [
    {
      name: 'Bali',
      status: 'occupied',
      guestName: 'Иванов Александр',
      guestNameMasked: 'И. А.',
      composition: '2A',
      flag: '🇱🇻',
      countryCode: 'LV',
      arrivalDate: '2026-04-12',
      departureDate: '2026-04-16',
      nightsTotal: 4,
      nightsRemaining: 2,
      revenue: 820,
      note: 'Просили поздний check-out',
      nextArrivalGuest: null,
      nextArrivalGuestMasked: null,
      nextArrivalDate: null,
    },
    {
      name: 'Cyprus',
      status: 'occupied',
      guestName: 'Raudsepp Liis',
      guestNameMasked: 'R. L.',
      composition: '2A',
      flag: '🇪🇪',
      countryCode: 'EE',
      arrivalDate: '2026-04-14',
      departureDate: '2026-04-16',
      nightsTotal: 2,
      nightsRemaining: 2,
      revenue: 540,
      note: 'Baby cot needed',
      nextArrivalGuest: null,
      nextArrivalGuestMasked: null,
      nextArrivalDate: null,
    },
    {
      name: 'Portofino',
      status: 'occupied',
      guestName: 'Орлова Марина',
      guestNameMasked: 'О. М.',
      composition: '2A',
      flag: '🇱🇻',
      countryCode: 'LV',
      arrivalDate: '2026-04-12',
      departureDate: '2026-04-14',
      nightsTotal: 2,
      nightsRemaining: 0,
      revenue: 520,
      note: null,
      nextArrivalGuest: null,
      nextArrivalGuestMasked: null,
      nextArrivalDate: null,
    },
    {
      name: 'Latvia',
      status: 'empty',
      guestName: null,
      guestNameMasked: null,
      composition: null,
      flag: null,
      countryCode: null,
      arrivalDate: null,
      departureDate: null,
      nightsTotal: null,
      nightsRemaining: null,
      revenue: null,
      note: null,
      nextArrivalGuest: 'Kalnina Elina',
      nextArrivalGuestMasked: 'K. E.',
      nextArrivalDate: '2026-04-14',
    },
    {
      name: 'Maldives',
      status: 'occupied',
      guestName: 'Петрова Елена',
      guestNameMasked: 'П. Е.',
      composition: '1A · 1C',
      flag: '🇫🇮',
      countryCode: 'FI',
      arrivalDate: '2026-04-13',
      departureDate: '2026-04-16',
      nightsTotal: 3,
      nightsRemaining: 2,
      revenue: 760,
      note: 'Allergy: nuts in SPA',
      nextArrivalGuest: null,
      nextArrivalGuestMasked: null,
      nextArrivalDate: null,
    },
  ],
  arrivals: [
    {
      time: '13:00',
      cabin: 'Cyprus',
      guestName: 'Raudsepp Liis',
      guestNameMasked: 'R. L.',
      composition: '2A',
      flag: '🇪🇪',
      countryCode: 'EE',
      nights: 2,
      arrivalDate: '2026-04-14',
      departureDate: '2026-04-16',
      channel: 'Booking.com',
      repeatVisitCount: 1,
      isNewGuest: true,
      note: 'Baby cot needed',
      checklist: [
        { label: { ru: 'Домик подготовлен', en: 'Cabin prepared' }, done: true },
        { label: { ru: 'Welcome-сообщение отправлено', en: 'Welcome message sent' }, done: true },
        { label: { ru: 'Детская кроватка', en: 'Baby cot' }, done: false },
      ],
    },
    {
      time: '16:00',
      cabin: 'Latvia',
      guestName: 'Kalnina Elina',
      guestNameMasked: 'K. E.',
      composition: '2A',
      flag: '🇱🇻',
      countryCode: 'LV',
      nights: 2,
      arrivalDate: '2026-04-14',
      departureDate: '2026-04-16',
      channel: 'Instagram DM',
      repeatVisitCount: 2,
      isNewGuest: false,
      note: 'Prefers quiet cabin',
      checklist: [
        { label: { ru: 'Домик подготовлен', en: 'Cabin prepared' }, done: false },
        { label: { ru: 'Welcome-сообщение отправлено', en: 'Welcome message sent' }, done: true },
        { label: { ru: 'Welcome wine', en: 'Welcome wine' }, done: false },
      ],
    },
  ],
  departures: [
    {
      deadline: '12:00',
      cabin: 'Portofino',
      guestName: 'Орлова Марина',
      guestNameMasked: 'О. М.',
      composition: '2A',
      flag: '🇱🇻',
      countryCode: 'LV',
      nights: 2,
      arrivalDate: '2026-04-12',
      departureDate: '2026-04-14',
      totalSpent: 520,
      isFirstVisit: true,
      checklist: [
        { label: { ru: 'Запросить отзыв', en: 'Request review' }, done: false },
        { label: { ru: 'Внести в CRM', en: 'Add to CRM' }, done: false },
        { label: { ru: 'Send thank-you email', en: 'Send thank-you email' }, done: false },
        { label: { ru: 'Сохранить заметки', en: 'Save notes' }, done: true },
      ],
    },
  ],
  spaSchedule: [
    { time: '10:00', treatment: 'Massage couple', durationMin: 60, guestName: 'Иванов А.', guestNameMasked: 'И. А.', cabin: 'Bali', price: 140 },
    { time: '11:00', free: true },
    { time: '12:00', treatment: 'Hammam access', durationMin: 90, guestName: 'Петрова Е. + family', guestNameMasked: 'П. Е. + family', cabin: 'Maldives', price: 180 },
    { time: '13:30', treatment: 'Body treatment', durationMin: 90, guestName: 'Иванов А.', guestNameMasked: 'И. А.', cabin: 'Bali', price: 120 },
    { time: '15:00', free: true },
    { time: '16:00', treatment: 'Massage solo', durationMin: 45, guestName: 'Raudsepp L.', guestNameMasked: 'R. L.', cabin: 'Cyprus', price: 80 },
    { time: '17:00', free: true },
    { time: '18:00', treatment: 'Couple package', durationMin: 120, guestName: 'Петрова Е.', guestNameMasked: 'П. Е.', cabin: 'Maldives', price: 200 },
    { time: '19:00', free: true },
    { time: '20:00', treatment: 'Express facial', durationMin: 45, guestName: 'Kalnina E.', guestNameMasked: 'K. E.', cabin: 'Latvia', price: 80 },
  ],
  spaSummary: {
    occupiedSlots: 6,
    totalSlots: 10,
    guestCount: 5,
    revenue: 800,
    freeSlots: ['11:00', '15:00', '17:00', '19:00'],
    crossSellHint: 'Сидоров К., Орлова М.',
    crossSellHintMasked: 'С. К., О. М.',
  },
  // Mock limitation: timeAgo strings are fixed for the April 14, 2026 prototype day.
  messages: [
    {
      id: 'msg-1',
      platform: 'whatsapp',
      from: '+371 2X XXX XXX',
      fromMasked: '+371 ** *** ***',
      fromIsNewContact: true,
      fromIsExistingGuest: false,
      ageMinutes: 14,
      timeAgo: { ru: '14 мин назад', en: '14 min ago' },
      preview: 'Здравствуйте! Хотел бы забронировать домик на 25-27 апреля, 2 взрослых. Можно с собакой?',
    },
    {
      id: 'msg-2',
      platform: 'instagram',
      from: '@marina_riga',
      fromMasked: '@m***',
      fromIsNewContact: true,
      fromIsExistingGuest: false,
      ageMinutes: 72,
      timeAgo: { ru: '1 ч 12 мин назад', en: '1h 12m ago' },
      preview: 'Подскажите, есть ли свободные домики на майские праздники? И сколько стоит?',
    },
    {
      id: 'msg-3',
      platform: 'messenger',
      from: 'Anna Petrova',
      fromMasked: 'A. P.',
      fromIsNewContact: false,
      fromIsExistingGuest: true,
      ageMinutes: 158,
      timeAgo: { ru: '2 ч 38 мин назад', en: '2h 38m ago' },
      preview: 'Привет! У нас завтра заезд в Maldives. Можем приехать на 2 часа раньше? Заранее спасибо!',
    },
  ],
};

const ov2AddDays = (base, offset) => {
  const d = new Date(base);
  d.setDate(d.getDate() + offset);
  return d;
};
const ov2Initials = (name) => String(name || '')
  .replace(/[.,]/g, ' ')
  .split(/\s+/)
  .filter((part) => part && /[A-Za-zА-Яа-яЁё]/.test(part[0]))
  .slice(0, 2)
  .map((part) => part[0].toUpperCase())
  .join('');
const ov2BuildHeatmap = () => {
  const startOffset = -7;
  const days = 30;
  const todayIndex = Math.abs(startOffset);
  const startDate = ov2IsoDate(ov2AddDays(OV2_TODAY, startOffset));
  const hmDateOffset = (offset) => ov2IsoDate(ov2AddDays(OV2_TODAY, offset));
  const cells = OV2_CABINS.map((cabin) => Array.from({ length: days }, (_, day) => ({
    date: hmDateOffset(startOffset + day),
    status: 'empty',
    cabin,
    day,
  })));
  const channelDiscovery = {
    Direct: ['Returning guest', 'Google search', 'Word of mouth'],
    Phone: ['Google search', 'Word of mouth'],
    'Booking.com': ['Booking.com', 'Google search'],
    WhatsApp: ['Instagram', 'Word of mouth', 'Returning guest'],
    Expedia: ['Expedia', 'Google Ads'],
    'Instagram DM': ['Instagram', 'Meta Ads'],
  };
  const paymentStatuses = ['paid', 'partial', 'unpaid', 'hold'];
  const paymentMethods = {
    paid: ['Card', 'Bank transfer', 'Apple Pay'],
    partial: ['Deposit card', 'Bank transfer'],
    unpaid: ['Pending invoice', 'Pay on arrival'],
    hold: ['Card hold', 'Pre-auth'],
  };
  const discoveryConfidenceBySource = {
    Instagram: 'manual',
    'Meta Ads': 'ga4',
    'Google search': 'ga4',
    'Google Ads': 'ga4',
    'Word of mouth': 'manual',
    'Returning guest': 'manual',
    'Booking.com': 'manual',
    Expedia: 'manual',
  };
  const ov2StaySeed = (id) => String(id || '').split('').reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
  const ov2GuestEmail = (name, id) => {
    const slug = String(name || 'guest').toLowerCase().replace(/[^a-zа-яё0-9]+/gi, '.').replace(/^\.+|\.+$/g, '') || 'guest';
    return `${slug}.${String(id || '').slice(-2)}@example.com`;
  };
  const ov2GuestPhone = (countryCode, seed) => `+${countryCode === 'LV' ? '371' : '371'} 2${seed % 10} ${String(100000 + (seed * 37) % 899999).replace(/(\d{3})(\d{3})/, '$1 $2')}`;

  const stays = [
    { id: 'HM-2801', cabin: 'Bali', guestName: 'Ozols Martins', guestNameMasked: 'O. M.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: -6, nights: 2, nightRate: 210, channel: 'Direct', repeatVisitCount: 2, note: 'Sauna before arrival', arrivalTime: '15:00' },
    { id: 'HM-2802', cabin: 'Bali', guestName: 'Ivanova Marina', guestNameMasked: 'I. M.', composition: '1A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: -3, nights: 2, nightRate: 190, channel: 'Phone', repeatVisitCount: 2, note: null, arrivalTime: '17:00' },
    { id: 'HM-2851', cabin: 'Bali', guestName: 'Иванов Александр', guestNameMasked: 'И. А.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: -1, nights: 3, nightRate: 207, channel: 'Direct', repeatVisitCount: 3, note: 'Просили поздний check-out', arrivalTime: '14:00' },
    { id: 'HM-2856', cabin: 'Bali', guestName: 'Anderson Mark', guestNameMasked: 'A. M.', composition: '2A', flag: '🇺🇸', countryCode: 'US', arrivalOffset: 3, nights: 3, nightRate: 280, channel: 'Direct', repeatVisitCount: 2, note: 'Anniversary couple, send champagne', arrivalTime: '16:00' },
    { id: 'HM-2871', cabin: 'Bali', guestName: 'Lehtonen Oskari', guestNameMasked: 'L. O.', composition: '2A', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 8, nights: 4, nightRate: 235, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2872', cabin: 'Bali', guestName: 'Klein Anna', guestNameMasked: 'K. A.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: 15, nights: 3, nightRate: 295, channel: 'Expedia', repeatVisitCount: 3, note: 'Vegetarian breakfast', arrivalTime: '15:30' },
    { id: 'HM-2873', cabin: 'Bali', guestName: 'Saar Maarika', guestNameMasked: 'S. M.', composition: '2A · 1C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 20, nights: 2, nightRate: 260, channel: 'Instagram DM', repeatVisitCount: 1, note: null, arrivalTime: '17:00' },

    { id: 'HM-2811', cabin: 'Cyprus', guestName: 'Jankauskas Tomas', guestNameMasked: 'J. T.', composition: '2A · 1C', flag: '🇱🇹', countryCode: 'LT', arrivalOffset: -7, nights: 2, nightRate: 260, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2812', cabin: 'Cyprus', guestName: 'Petraitis Egle', guestNameMasked: 'P. E.', composition: '2A', flag: '🇱🇹', countryCode: 'LT', arrivalOffset: -4, nights: 1, nightRate: 240, channel: 'Direct', repeatVisitCount: 1, note: 'Late check-out requested', arrivalTime: '18:00' },
    { id: 'HM-2852', cabin: 'Cyprus', guestName: 'Raudsepp Liis', guestNameMasked: 'R. L.', composition: '2A · 1C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 0, nights: 2, nightRate: 270, channel: 'Booking.com', repeatVisitCount: 1, note: 'Baby cot needed', arrivalTime: '13:30' },
    { id: 'HM-2881', cabin: 'Cyprus', guestName: 'Nowak Marta', guestNameMasked: 'N. M.', composition: '2A', flag: '🇵🇱', countryCode: 'PL', arrivalOffset: 4, nights: 3, nightRate: 240, channel: 'Expedia', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2882', cabin: 'Cyprus', guestName: 'Berzina Liene', guestNameMasked: 'B. L.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: 9, nights: 2, nightRate: 215, channel: 'WhatsApp', repeatVisitCount: 2, note: null, arrivalTime: '16:00' },
    { id: 'HM-2883', cabin: 'Cyprus', guestName: 'Hoffmann Dieter', guestNameMasked: 'H. D.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: 13, nights: 4, nightRate: 260, channel: 'Direct', repeatVisitCount: 5, note: 'Same wine as last stay', arrivalTime: '14:30' },
    { id: 'HM-2884', cabin: 'Cyprus', guestName: 'Laine Sofia', guestNameMasked: 'L. S.', composition: '2A', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 19, nights: 3, nightRate: 245, channel: 'Booking.com', repeatVisitCount: 4, note: null, arrivalTime: '17:00' },
    { id: 'HM-2885', cabin: 'Cyprus', guestName: 'Tamm Kristjan', guestNameMasked: 'T. K.', composition: '2A · 2C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 22, nights: 1, nightRate: 305, channel: 'Instagram DM', repeatVisitCount: 1, note: null, arrivalTime: '16:00' },

    { id: 'HM-2821', cabin: 'Portofino', guestName: 'Kask Anna', guestNameMasked: 'K. A.', composition: '2A', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: -6, nights: 3, nightRate: 230, channel: 'WhatsApp', repeatVisitCount: 3, note: 'Anniversary couple, send champagne', arrivalTime: '16:00' },
    { id: 'HM-2822', cabin: 'Portofino', guestName: 'Sokolov Andrei', guestNameMasked: 'S. A.', composition: '2A · 1C', flag: '🇷🇺', countryCode: 'RU', arrivalOffset: -2, nights: 1, nightRate: 260, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '18:00' },
    { id: 'HM-2891', cabin: 'Portofino', guestName: 'Berg Henrik', guestNameMasked: 'B. H.', composition: '1A', flag: '🇳🇴', countryCode: 'NO', arrivalOffset: 1, nights: 1, nightRate: 280, channel: 'Phone', repeatVisitCount: 2, note: null, arrivalTime: '15:00' },
    { id: 'HM-2892', cabin: 'Portofino', guestName: 'Vasiljev Dmitri', guestNameMasked: 'V. D.', composition: '2A · 1C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 5, nights: 2, nightRate: 260, channel: 'WhatsApp', repeatVisitCount: 1, note: 'SPA package requested', arrivalTime: '16:30' },
    { id: 'HM-2893', cabin: 'Portofino', guestName: 'Petrov Kirill', guestNameMasked: 'P. K.', composition: '1A', flag: '🇷🇺', countryCode: 'RU', arrivalOffset: 10, nights: 2, nightRate: 210, channel: 'Phone', repeatVisitCount: 1, note: 'Confirmation call needed', arrivalTime: '15:00' },
    { id: 'HM-2896', cabin: 'Portofino', guestName: 'Berzina Liene', guestNameMasked: 'B. L.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: 4, nights: 1, nightRate: 245, channel: 'WhatsApp', repeatVisitCount: 2, note: null, arrivalTime: '14:30' },
    { id: 'HM-2897', cabin: 'Portofino', guestName: 'Laine Sofia', guestNameMasked: 'L. S.', composition: '2A', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 14, nights: 1, nightRate: 275, channel: 'Direct', repeatVisitCount: 4, note: null, arrivalTime: '15:30' },
    { id: 'HM-2894', cabin: 'Portofino', guestName: 'Korhonen Mika', guestNameMasked: 'K. M.', composition: '2A · 2C', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 16, nights: 4, nightRate: 280, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '14:00' },
    { id: 'HM-2895', cabin: 'Portofino', guestName: 'Muller Felix', guestNameMasked: 'M. F.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: 21, nights: 1, nightRate: 255, channel: 'Instagram DM', repeatVisitCount: 1, note: null, arrivalTime: '18:00' },

    { id: 'HM-2831', cabin: 'Latvia', guestName: 'Schmidt Laura', guestNameMasked: 'S. L.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: -5, nights: 2, nightRate: 220, channel: 'Expedia', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2854', cabin: 'Latvia', guestName: 'Kalnina Elina', guestNameMasked: 'K. E.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: -1, nights: 2, nightRate: 230, channel: 'Instagram DM', repeatVisitCount: 1, note: 'Prefers quiet cabin', arrivalTime: '14:00' },
    { id: 'HM-2901', cabin: 'Latvia', guestName: 'Orlova Marina', guestNameMasked: 'O. M.', composition: '2A', flag: '🇱🇻', countryCode: 'LV', arrivalOffset: 2, nights: 2, nightRate: 245, channel: 'Direct', repeatVisitCount: 1, note: 'Welcome wine', arrivalTime: '16:00' },
    { id: 'HM-2902', cabin: 'Latvia', guestName: 'Virtanen Aino', guestNameMasked: 'V. A.', composition: '2A · 2C', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 7, nights: 3, nightRate: 295, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '15:30' },
    { id: 'HM-2903', cabin: 'Latvia', guestName: 'Saar Maarika', guestNameMasked: 'S. M.', composition: '2A · 1C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 12, nights: 2, nightRate: 260, channel: 'Direct', repeatVisitCount: 1, note: null, arrivalTime: '16:30' },
    { id: 'HM-2904', cabin: 'Latvia', guestName: 'Klein Anna', guestNameMasked: 'K. A.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: 15, nights: 1, nightRate: 300, channel: 'Expedia', repeatVisitCount: 3, note: null, arrivalTime: '14:00' },
    { id: 'HM-2905', cabin: 'Latvia', guestName: 'Petraitis Egle', guestNameMasked: 'P. E.', composition: '2A', flag: '🇱🇹', countryCode: 'LT', arrivalOffset: 18, nights: 3, nightRate: 255, channel: 'WhatsApp', repeatVisitCount: 1, note: null, arrivalTime: '17:00' },
    { id: 'HM-2906', cabin: 'Latvia', guestName: 'Nowak Marta', guestNameMasked: 'N. M.', composition: '2A', flag: '🇵🇱', countryCode: 'PL', arrivalOffset: 21, nights: 1, nightRate: 250, channel: 'Expedia', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },

    { id: 'HM-2841', cabin: 'Maldives', guestName: 'Korhonen Mika', guestNameMasked: 'K. M.', composition: '2A · 2C', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: -7, nights: 3, nightRate: 280, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2842', cabin: 'Maldives', guestName: 'Laine Sofia', guestNameMasked: 'L. S.', composition: '2A', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: -2, nights: 2, nightRate: 235, channel: 'WhatsApp', repeatVisitCount: 4, note: 'Allergy: nuts in SPA', arrivalTime: '18:00' },
    { id: 'HM-2855', cabin: 'Maldives', guestName: 'Virtanen Aino', guestNameMasked: 'V. A.', composition: '2A · 2C', flag: '🇫🇮', countryCode: 'FI', arrivalOffset: 0, nights: 3, nightRate: 295, channel: 'Booking.com', repeatVisitCount: 1, note: null, arrivalTime: '15:00' },
    { id: 'HM-2911', cabin: 'Maldives', guestName: 'Tamm Kristjan', guestNameMasked: 'T. K.', composition: '2A · 2C', flag: '🇪🇪', countryCode: 'EE', arrivalOffset: 5, nights: 3, nightRate: 305, channel: 'Instagram DM', repeatVisitCount: 1, note: null, arrivalTime: '16:30' },
    { id: 'HM-2912', cabin: 'Maldives', guestName: 'Hoffmann Dieter', guestNameMasked: 'H. D.', composition: '2A', flag: '🇩🇪', countryCode: 'DE', arrivalOffset: 11, nights: 4, nightRate: 300, channel: 'Direct', repeatVisitCount: 5, note: 'Same wine as last stay', arrivalTime: '14:00' },
    { id: 'HM-2913', cabin: 'Maldives', guestName: 'Nowak Marta', guestNameMasked: 'N. M.', composition: '2A', flag: '🇵🇱', countryCode: 'PL', arrivalOffset: 17, nights: 1, nightRate: 280, channel: 'Expedia', repeatVisitCount: 1, note: null, arrivalTime: '17:30' },
    { id: 'HM-2914', cabin: 'Maldives', guestName: 'Anderson Mark', guestNameMasked: 'A. M.', composition: '2A', flag: '🇺🇸', countryCode: 'US', arrivalOffset: 21, nights: 3, nightRate: 320, channel: 'Direct', repeatVisitCount: 2, note: null, arrivalTime: '16:00' },
  ];

  stays.forEach((stay) => {
    const cabinIndex = OV2_CABINS.indexOf(stay.cabin);
    if (cabinIndex < 0) return;
    const start = stay.arrivalOffset - startOffset;
    const end = start + stay.nights;
    const visibleStart = Math.max(0, start);
    const visibleEnd = Math.min(days, end);
    const status = end <= todayIndex ? 'checked_out' : start <= todayIndex && end > todayIndex ? 'in_house' : 'confirmed';
    const seed = ov2StaySeed(stay.id);
    const totalRevenue = stay.nightRate * stay.nights;
    const discoveryOptions = channelDiscovery[stay.channel] || ['Unknown'];
    const discovery = stay.discovery || discoveryOptions[seed % discoveryOptions.length];
    const discoveryConfidence = stay.discoveryConfidence || discoveryConfidenceBySource[discovery] || 'unknown';
    const paymentStatus = stay.paymentStatus || paymentStatuses[(seed + cabinIndex + Math.max(0, stay.arrivalOffset)) % paymentStatuses.length];
    const methodOptions = paymentMethods[paymentStatus] || ['Pending'];
    const amountPaid = paymentStatus === 'paid'
      ? totalRevenue
      : paymentStatus === 'partial'
        ? Math.round(totalRevenue * 0.35)
        : 0;
    const balanceDue = Math.max(0, totalRevenue - amountPaid);
    const bookingOffset = stay.bookingOffset || stay.arrivalOffset - (7 + (seed % 24));
    const guestSegment = stay.repeatVisitCount > 3
      ? 'VIP repeat'
      : stay.repeatVisitCount > 1
        ? 'Returning'
        : String(stay.composition || '').includes('C')
          ? 'Family'
          : 'New guest';
    for (let day = visibleStart; day < visibleEnd; day += 1) {
      cells[cabinIndex][day] = {
        ...cells[cabinIndex][day],
        status,
        guestInitials: ov2Initials(stay.guestName),
        guestName: stay.guestName,
        guestNameMasked: stay.guestNameMasked,
        composition: stay.composition,
        flag: stay.flag,
        countryCode: stay.countryCode,
        bookingId: stay.id,
        nightRate: stay.nightRate,
        isArrival: day === start,
        isDeparture: day === end - 1,
        arrivalTime: day === start ? stay.arrivalTime : null,
        departureTime: day === end - 1 ? '12:00' : null,
        arrivalDate: hmDateOffset(stay.arrivalOffset),
        departureDate: hmDateOffset(stay.arrivalOffset + stay.nights),
        bookingDate: hmDateOffset(bookingOffset),
        leadTimeDays: Math.max(0, stay.arrivalOffset - bookingOffset),
        nights: stay.nights,
        totalRevenue,
        repeatVisitCount: stay.repeatVisitCount,
        channel: stay.channel,
        discovery,
        discoveryConfidence,
        guestEmail: ov2GuestEmail(stay.guestName, stay.id),
        guestPhone: ov2GuestPhone(stay.countryCode, seed),
        guestSegment,
        language: stay.countryCode === 'LV' ? 'LV/RU' : stay.countryCode === 'RU' ? 'RU/EN' : 'EN',
        paymentStatus,
        paymentMethod: methodOptions[seed % methodOptions.length],
        amountPaid,
        balanceDue,
        paymentDueDate: paymentStatus === 'paid' ? null : hmDateOffset(stay.arrivalOffset),
        note: stay.note,
      };
    }
  });

  [
    { cabin: 'Portofino', startOffset: 2, days: 2, reason: 'Maintenance deep clean' },
    { cabin: 'Latvia', startOffset: 22, days: 1, reason: 'Owner block' },
  ].forEach((block) => {
    const cabinIndex = OV2_CABINS.indexOf(block.cabin);
    if (cabinIndex < 0) return;
    const start = block.startOffset - startOffset;
    for (let i = 0; i < block.days; i += 1) {
      const day = start + i;
      if (day < 0 || day >= days) continue;
      cells[cabinIndex][day] = {
        ...cells[cabinIndex][day],
        status: 'blocked',
        blockReason: block.reason,
        isArrival: false,
        isDeparture: false,
      };
    }
  });

  const dayAggregates = Array.from({ length: days }, (_, day) => {
    const dateObj = ov2AddDays(OV2_TODAY, startOffset + day);
    const occupiedCount = cells.filter((row) => ['confirmed', 'in_house', 'checked_out'].includes(row[day].status)).length;
    const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
    return {
      date: ov2IsoDate(dateObj),
      dayOfWeek: dateObj.getDay(),
      dayOfMonth: dateObj.getDate(),
      monthAbbr: dateObj.toLocaleString('en-US', { month: 'short' }),
      isWeekend,
      isToday: day === todayIndex,
      adrMedian: isWeekend ? 280 : 240,
      occupiedCount,
      totalCabins: OV2_CABINS.length,
    };
  });

  const weekDefs = [
    { label: 'Week 1', start: 0, end: 6, paceVsAvg: 5 },
    { label: 'Week 2', start: 7, end: 13, paceVsAvg: 2 },
    { label: 'Week 3', start: 14, end: 21, paceVsAvg: -3 },
    { label: 'Week 4', start: 22, end: 29, paceVsAvg: -8 },
  ];
  const weeks = weekDefs.map((week, index) => {
    let occupied = 0;
    let revenue = 0;
    for (let day = week.start; day <= week.end; day += 1) {
      cells.forEach((row) => {
        const cell = row[day];
        if (['confirmed', 'in_house', 'checked_out'].includes(cell.status)) {
          occupied += 1;
          revenue += cell.nightRate || 0;
        }
      });
    }
    const total = (week.end - week.start + 1) * OV2_CABINS.length;
    const severity = week.paceVsAvg >= 3 ? 'positive' : week.paceVsAvg >= 0 ? 'neutral' : week.paceVsAvg <= -6 ? 'critical-warn' : 'mild-warn';
    return {
      label: week.label,
      n: index + 1,
      startIndex: week.start,
      endIndex: week.end,
      startDate: dayAggregates[week.start].date,
      endDate: dayAggregates[week.end].date,
      occupied,
      total,
      pct: Math.round((occupied / total) * 100),
      revenue,
      paceVsAvg: week.paceVsAvg,
      paceSeverity: severity,
    };
  });

  const occupied = weeks.reduce((sum, week) => sum + week.occupied, 0);
  const total = weeks.reduce((sum, week) => sum + week.total, 0);
  return {
    cabins: OV2_CABINS,
    startDate,
    days,
    todayIndex,
    cells,
    dayAggregates,
    weeks,
    summary: {
      occupied,
      total,
      pct: Math.round((occupied / total) * 100),
      revenue: 18400,
    },
  };
};

const OV2 = {
  today: ov2IsoDate(OV2_TODAY),
  cabins: OV2_CABINS,
  pulse: OV2_PULSE,
  // KPIs (default state)
  default: {
    revenue: { value: '€42,580', delta: 12, deltaSign: 'pos', spark: series30(7, 1400, 0.4) },
    rpg: { value: '€385', delta: 6, deltaSign: 'pos', spark: series30(11, 380, 0.18) },
    occupancy: { value: '118', total: '150', pct: '78%', delta: '+8 ночей', deltaSign: 'pos', spark: series30(13, 4, 0.3) },
    gop: { value: '34%', sub: 'GOP €14,477', delta: '+2 п.п.', deltaSign: 'pos', spark: series30(17, 33, 0.12) },
    spend: { value: '€4,820', sub: 'G €2,100 · M €1,800 · TT €920' },
    roas: { value: '8.8x', delta: '−0.6x', deltaSign: 'warn' },
    cac: { value: '€68', sub: '71 новых гостей', delta: '+€12', deltaSign: 'neg' },
    direct: { value: '54%', sub: 'Сэкономлено ~€2,300 на комиссиях', delta: '+4 п.п.', deltaSign: 'pos' },
  },
  // KPIs (alert state — overrides)
  alert: {
    revenue: { value: '€36,140', delta: 4, deltaSign: 'pos', spark: series30(7, 1200, 0.4) },
    rpg: { value: '€352', delta: -3, deltaSign: 'neg', spark: series30(11, 350, 0.18) },
    occupancy: { value: '102', total: '150', pct: '68%', delta: '−8 ночей', deltaSign: 'neg', spark: series30(13, 3.5, 0.3) },
    gop: { value: '28%', sub: 'GOP €10,119', delta: '−6 п.п.', deltaSign: 'neg', alert: true, spark: series30(17, 28, 0.12) },
    spend: { value: '€6,210', sub: 'G €2,800 · M €2,400 · TT €1,010' },
    roas: { value: '5.8x', delta: '−3.0x', deltaSign: 'neg' },
    cac: { value: '€88', sub: '71 новых гостей', delta: '+€32 (+57%)', deltaSign: 'neg', alert: true },
    direct: { value: '49%', sub: 'Сэкономлено ~€1,860 на комиссиях', delta: '−1 п.п.', deltaSign: 'flat' },
  },
  // Pace
  pace: [
    { label: 'Ближайшие 7 дней', pct: 97, num: '34/35', cmp: '+2 ночи к среднему', cmpKind: 'pos', barKind: 'green' },
    { label: 'Дни 8–14', pct: 80, num: '28/35', cmp: '−1 ночь к среднему', cmpKind: 'flat', barKind: 'green' },
    { label: 'Дни 15–30', pct: 65, num: '52/80', cmp: '+5 ночей к среднему', cmpKind: 'pos', barKind: 'lgreen' },
    { label: 'Дни 31–60', pct: 41, num: '61/150', cmp: '−8 ночей ⚠', cmpKind: 'neg', barKind: 'amber' },
  ],
  paceAlert: [
    { label: 'Ближайшие 7 дней', pct: 86, num: '30/35', cmp: '−2 ночи', cmpKind: 'flat', barKind: 'green' },
    { label: 'Дни 8–14', pct: 60, num: '21/35', cmp: '−6 ночей', cmpKind: 'neg', barKind: 'lgreen' },
    { label: 'Дни 15–30', pct: 45, num: '36/80', cmp: '−9 ночей ⚠', cmpKind: 'neg', barKind: 'amber' },
    { label: 'Дни 31–60', pct: 28, num: '42/150', cmp: '−14 ночей ⚠', cmpKind: 'neg', barKind: 'red' },
  ],
  // Loyalty
  loyalty: [
    { lbl: 'Repeat guest rate', val: '28%', delta: '+3 п.п.', deltaSign: 'pos' },
    { lbl: 'Avg review score', val: '9.1', sub: '/10', mini: 'B 9.0 · G 9.3 · TA 9.0' },
    { lbl: 'Новых отзывов', val: '23', delta: '+5', deltaSign: 'pos' },
  ],
  // Stacked area chart — 30 days, 5 streams (rooms, spa, fnb, other, padel-disabled)
  chartStreams: (() => {
    const days = 30;
    const r = seed(31);
    return Array.from({ length: days }, (_, i) => {
      const w = (i % 7 >= 5) ? 1.4 : 1;
      const rooms = Math.round(720 * w * (1 + (r() - 0.5) * 0.25));
      const spa = Math.round(260 * w * (1 + (r() - 0.5) * 0.3));
      const fnb = Math.round(150 * w * (1 + (r() - 0.5) * 0.4));
      const other = Math.round(40 * (1 + (r() - 0.5) * 0.5));
      const padel = 0;
      return { rooms, spa, fnb, other, padel };
    });
  })(),
  // Heatmap — 5 cabins × 30 days with per-cell booking details
  heatmap: ov2BuildHeatmap(),
  bookings: OV2_BOOKINGS,
};

OV2.deriveForPeriod = function(baseData, period, compare, isAlert) {
  const safePeriod = ['7d', '30d', '90d', 'ytd'].includes(period) ? period : '30d';
  const safeCompare = ['prev', 'ly', 'avg90'].includes(compare) ? compare : 'prev';
  const source = isAlert ? { ...OV2.default, ...OV2.alert } : (baseData || OV2.default);
  if (!isAlert && safePeriod === '30d' && safeCompare === 'prev') {
    return {
      ...source,
      loyalty: OV2.loyalty,
      chartStreams: OV2.chartStreams,
      periodDays: 30,
      periodScale: 1,
      compare: safeCompare,
      period: safePeriod,
    };
  }
  const volumeScale = ov2PeriodScale(safePeriod, isAlert ? 211 : 101);
  const days = ov2PeriodDays(safePeriod);
  const volumeDelta = ov2CompareVolumeDelta(safeCompare, isAlert);
  const rateDelta = ov2CompareRateDelta(safeCompare, isAlert);
  const deltaSign = isAlert ? 'neg' : 'pos';
  const sparkLen = safePeriod === '7d' ? 7 : 30;
  const revenue = ov2Num(source.revenue.value) * volumeScale;
  const spend = ov2Num(source.spend.value) * volumeScale;
  const occupancyTotal = Math.max(1, days * OV2_CABINS.length);
  const occupancyPct = ov2Num(source.occupancy.pct) || (ov2Num(source.occupancy.value) / ov2Num(source.occupancy.total)) * 100 || 75;
  const occupancyNights = Math.max(1, Math.round(occupancyTotal * (isAlert ? Math.max(45, occupancyPct) : occupancyPct) / 100));
  const rpg = ov2AvgJitter(ov2Num(source.rpg.value), safePeriod, 301);
  const gop = ov2AvgJitter(ov2Num(source.gop.value), safePeriod, 307);
  const roas = ov2AvgJitter(ov2Num(source.roas.value), safePeriod, 313);
  const cac = ov2AvgJitter(ov2Num(source.cac.value), safePeriod, 317);
  const direct = ov2AvgJitter(ov2Num(source.direct.value), safePeriod, 331);
  const newGuests = Math.max(1, Math.round(71 * volumeScale));
  const occupancyDelta = Math.max(1, Math.round(occupancyNights * Math.abs(volumeDelta) / 100));
  const ppSuffix = ' п.п.';
  const deltaPrefix = isAlert ? '−' : '+';

  const chartStreams = Array.from({ length: sparkLen }, (_, i) => {
    const r = seed(800 + i + safePeriod.length * 17 + (isAlert ? 9 : 0));
    const trend = 1 + i * (safePeriod === '7d' ? 0.015 : 0.006);
    const dayScale = volumeScale * (30 / sparkLen);
    const weekend = i % 7 >= 5 ? 1.35 : 1;
    return {
      rooms: Math.round(720 * dayScale * weekend * trend * (0.9 + r() * 0.2)),
      spa: Math.round(260 * dayScale * weekend * trend * (0.88 + r() * 0.24)),
      fnb: Math.round(150 * dayScale * weekend * trend * (0.85 + r() * 0.3)),
      other: Math.round(40 * dayScale * trend * (0.8 + r() * 0.4)),
      padel: 0,
    };
  });

  return {
    ...source,
    revenue: {
      ...source.revenue,
      value: ov2Money(revenue),
      delta: volumeDelta,
      deltaSign,
      spark: seriesN(sparkLen, 7, revenue / Math.max(1, sparkLen), 0.35),
    },
    rpg: {
      ...source.rpg,
      value: ov2Money(rpg),
      delta: rateDelta,
      deltaSign,
      spark: seriesN(sparkLen, 11, rpg, 0.14),
    },
    occupancy: {
      ...source.occupancy,
      value: String(occupancyNights),
      total: String(occupancyTotal),
      pct: ov2Pct((occupancyNights / occupancyTotal) * 100),
      delta: `${deltaPrefix}${occupancyDelta} ночей`,
      deltaSign,
      spark: seriesN(sparkLen, 13, Math.max(1, occupancyNights / Math.max(1, sparkLen)), 0.28),
    },
    gop: {
      ...source.gop,
      value: ov2Pct(gop),
      delta: `${deltaPrefix}${Math.abs(rateDelta)}${ppSuffix}`,
      deltaSign,
      alert: isAlert,
      sub: source.gop.sub ? ov2ScaleMoneyInText(source.gop.sub, volumeScale) : source.gop.sub,
      spark: seriesN(sparkLen, 17, gop, 0.1),
    },
    spend: {
      ...source.spend,
      value: ov2Money(spend),
      sub: ov2ScaleMoneyInText(source.spend.sub, volumeScale),
      delta: `${deltaPrefix}${Math.abs(volumeDelta)}%`,
      deltaSign: isAlert ? 'warn' : 'flat',
    },
    roas: {
      ...source.roas,
      value: ov2X(roas),
      delta: `${deltaPrefix}${(Math.abs(rateDelta) / 10).toFixed(1)}x`,
      deltaSign,
    },
    cac: {
      ...source.cac,
      value: ov2Money(cac),
      sub: `${newGuests} новых гостей`,
      delta: `${isAlert ? '+' : '−'}€${Math.max(1, Math.round(Math.abs(rateDelta) * 4))}`,
      deltaSign: isAlert ? 'neg' : 'pos',
      alert: isAlert,
    },
    direct: {
      ...source.direct,
      value: ov2Pct(direct),
      delta: `${deltaPrefix}${Math.abs(rateDelta)}${ppSuffix}`,
      deltaSign,
      sub: source.direct.sub ? ov2ScaleMoneyInText(source.direct.sub, volumeScale) : source.direct.sub,
    },
    loyalty: [
      { ...OV2.loyalty[0], val: ov2Pct(ov2AvgJitter(28, safePeriod, 401)), delta: `${deltaPrefix}${Math.abs(rateDelta)}${ppSuffix}`, deltaSign },
      { ...OV2.loyalty[1], val: (ov2AvgJitter(9.1, safePeriod, 407) + (isAlert ? -0.2 : 0)).toFixed(1) },
      { ...OV2.loyalty[2], val: String(Math.max(1, Math.round(23 * volumeScale))), delta: `${deltaPrefix}${Math.max(1, Math.round(Math.abs(volumeDelta) / 3))}`, deltaSign },
    ],
    chartStreams,
    periodDays: days,
    periodScale: volumeScale,
    compare: safeCompare,
    period: safePeriod,
  };
};

const I18N_OV2 = {
  ru: {
    appName: 'SEOVILLAGE',
    tabs: ['Overview', 'Revenue', 'Marketing', 'Booking', 'Traffic', 'Customers'],
    period: {
      lbl: 'Последние 30 дней',
      opts: ['Today', 'Last 7 days', 'Last 30 days', 'Last 90 days', 'Custom...'],
      options: [
        { value: '7d', label: 'Последние 7 дней' },
        { value: '30d', label: 'Последние 30 дней' },
        { value: '90d', label: 'Последние 90 дней' },
        { value: 'ytd', label: 'С начала года' },
        { value: 'custom', label: 'Произвольный период...', disabled: true, hint: 'Скоро' },
      ],
    },
    compare: {
      lbl: 'vs Прошлый период',
      opts: ['vs Previous period', 'vs Last year', 'vs 90-day average'],
      options: [
        { value: 'prev', label: 'vs Прошлый период' },
        { value: 'ly', label: 'vs Прошлый год' },
        { value: 'avg90', label: 'vs Среднее 90 дней' },
      ],
    },
    updated: 'Обновлено 2 мин назад',
    pulse: {
      inhouse: { lbl: 'In-house', val: '4/5 домиков · 8 гостей' },
      arrivals: { lbl: 'Заездов сегодня', val: '2' },
      departures: { lbl: 'Выездов сегодня', val: '1' },
      spa: { lbl: 'СПА на сегодня', val: '6 процедур' },
      msg: { lbl: 'Новых сообщений', val: '3' },
      drawers: {
        inhouse: {
          title: 'In-house сейчас',
          summary: '{cabins} домика · {guests} гостей · {empty} свободен',
          checkedIn: 'Заехали {date}, {days} дней назад',
          departure: '{nights} ночей осталось · Выезд {date}',
          free: 'Свободен',
          nextArrival: 'Следующий заезд: {date} ({guest})',
          openBooking: 'Открыть бронь →',
          footerCTA: 'Открыть Booking Analytics →',
        },
        arrivals: {
          title: 'Заезды сегодня',
          summary: '{arrivals} заезда · {guests} гостя',
          newGuest: 'Новый гость',
          repeatNTimes: 'Repeat {count}x',
          checklistTitle: 'Чек-лист подготовки:',
          channel: 'Канал',
          nights: '{nights} ночей',
          sendWhatsApp: 'Send WhatsApp',
          openBooking: 'Open booking →',
          empty: 'Сегодня заездов нет. Следующий заезд: {date} · {cabin} · {guest}',
          footerCTA: 'Все заезды на неделю →',
        },
        departures: {
          title: 'Выезды сегодня',
          summary: '{departures} выезд · {guests} гостя',
          deadlineUntil: 'До {time}',
          totalSpent: 'Total spent: €{amount}',
          firstVisit: 'Первый визит',
          repeatGuest: 'Repeat {count}x',
          nights: '{nights} ночей',
          checklistTitle: 'После выезда:',
          sendThankYou: 'Send thank-you',
          openBooking: 'Open booking →',
          empty: 'Сегодня выездов нет.',
          footerCTA: 'Все выезды на неделю →',
        },
        spa: {
          title: 'СПА сегодня',
          summary: '{sessions} процедур · {guests} гостей · {free} слота свободно',
          freeSlot: '— Свободно —',
          duration: '{minutes}min',
          summaryLoad: 'Загрузка дня: {used}/{total} слотов · {pct}%',
          summaryRevenue: 'Выручка СПА сегодня: €{amount}',
          summaryFree: 'Свободные слоты: {list}',
          crossSellHint: '💡 {free} слота свободно — рассмотрите cross-sell гостям без записи ({guests})',
          footerCTA: 'Открыть СПА-календарь в Cloudbeds ↗',
        },
        messages: {
          title: 'Новые сообщения',
          summary: '{messages} unanswered · {old} unanswered > 30 min ⚠',
          newContact: 'новый контакт',
          existingGuest: 'existing guest',
          from: 'От',
          openPlatform: 'Open {platform} →',
          markHandled: 'Mark as handled',
          empty: 'Все сообщения обработаны 👍',
          footerCTA: 'Открыть все диалоги →',
        },
      },
    },
    hero: {
      revenue: 'Total revenue', rpg: 'Revenue per guest', occupancy: 'Occupancy (ночи)', gop: 'GOP margin',
      spend: 'Marketing spend', roas: 'Blended ROAS', cac: 'CAC', direct: 'Direct booking',
    },
    chart: {
      title: 'Revenue trend by stream',
      mix: 'Rooms 62% · SPA 22% · F&B 13% · Other 3%',
      legend: { rooms: 'Размещение', spa: 'СПА', fnb: 'F&B', other: 'Другое', padel: 'Padel' },
      padelHint: 'Доступно после запуска',
    },
    heatmap: {
      title: 'Загрузка · следующие 30 дней',
      sum: '87/150 · 58%',
      sidebarHeader: 'NEXT 30 DAYS',
      sidebarRevenue: '€{value} OTB',
      weekTitle: 'Week {n}',
      weekPaceVsAvg: 'Pace: {value} vs avg',
      weekendLabel: 'Выходные',
      todayLabel: 'Сегодня',
      legend: { free: 'свободно', empty: 'свободно', booked: 'бронь', inhouse: 'in-house', inHouse: 'in-house', arrival: 'заезд/выезд', payment: 'оплата', blocked: 'блок' },
      cabin: 'Дом.',
      payment: { paid: 'оплачено', partial: 'частично', unpaid: 'не оплачено', hold: 'hold' },
      daysOfWeek: ['В', 'П', 'В', 'С', 'Ч', 'П', 'С'],
      tooltip: {
        free: 'Свободно',
        blocked: 'Заблокировано: {reason}',
        medianADR: 'ADR этого дня: €{value} (median)',
        dayLoad: 'Загрузка на {date}: {pct}% ({n} из {total})',
        arrivalLabel: 'Заезд: {guest} · {time}',
        departureLabel: 'Выезд: {guest} · до {time}',
        channelLabel: 'Канал: {channel}',
        discoveryLabel: 'Discovery: {discovery}',
        paymentLabel: 'Оплата: {status}',
        balanceLabel: 'остаток €{value}',
        repeatLabel: 'Repeat {n}x',
      },
    },
    pace: {
      title: 'Booking pace · next 60 days',
      otb: 'OTB всего: 175 ночей',
    },
    loyalty: {
      title: 'Гости и репутация',
      score: '/10',
    },
    bookingsTable: {
      title: 'Бронирования за период',
      aggregateBookings: 'броней',
      aggregateNights: 'ночей',
      aggregateRevenue: 'выручки',
      aggregateInHouse: 'in-house сегодня',
      periodGlobal: 'Глобальный период',
      periodGlobalWithLabel: 'Глобальный период ({label})',
      today: 'Today',
      tomorrow: 'Tomorrow',
      thisWeek: 'This week',
      previousWeek: 'Previous week',
      thisMonth: 'This month',
      previousMonth: 'Previous month',
      customRange: 'Custom range...',
      customPeriod: 'Custom period',
      resetGlobal: 'Reset to global',
      compact: 'Compact',
      detailed: 'Detailed',
      export: 'Export',
      total: 'Total',
      showAll: 'Show all',
      bookings: 'bookings',
      filters: { status: 'Status', cabin: 'Cabin', channel: 'Channel', country: 'Country', discovery: 'Discovery' },
      clearAll: 'Clear all',
      resetFilters: 'Reset filters',
      activeFilters: 'Active filters',
      all: 'All',
      columns: {
        status: 'Status',
        cabin: 'Cabin',
        guest: 'Guest',
        country: 'Country',
        dates: 'Dates',
        guests: 'Guests',
        channel: 'Channel',
        discovery: 'Discovery',
        bookingDate: 'Booking date',
        leadTime: 'Lead time',
        notes: 'Notes',
        repeatStatus: 'Repeat',
        revenue: 'Revenue',
      },
      statuses: {
        in_house: 'In-house',
        confirmed: 'Confirmed',
        checked_out: 'Checked-out',
        cancelled: 'Cancelled',
        no_show: 'No-show',
      },
      confidence: {
        manual: 'Source was marked manually by the team',
        ga4: 'Source inferred from GA4 / attribution data',
        unknown: 'Source is missing or unknown',
      },
      repeat: 'Repeat',
      nightsShort: 'N',
      daysBefore: 'd before',
      emptyPeriod: 'За выбранный период нет броней. Попробуйте расширить период или выбрать другой диапазон дат.',
      emptyFilters: 'По фильтрам ничего не найдено.',
      discoveryWarning: 'Discovery source не указан для',
      discoveryAdvice: 'броней. Чтобы улучшить аналитику, отмечайте источник при чек-ине гостя.',
      dismiss: 'Dismiss',
      customModalTitle: 'Custom range',
      customModalSub: 'Выберите даты для локального периода блока.',
      from: 'From',
      to: 'To',
      apply: 'Apply',
      cancel: 'Cancel',
      comingSoon: 'Coming soon',
      openCloudbeds: 'Open in Cloudbeds',
      sendWhatsapp: 'Send WhatsApp',
      fullListSoon: 'Full bookings list coming soon',
    },
    drawer: {
      title: 'GOP margin · детализация',
      period: 'За последние 30 дней',
      pl: [
        { lbl: 'Выручка', val: '€42,580', kind: 'main' },
        { lbl: '— Размещение', val: '€26,400' },
        { lbl: '— СПА', val: '€9,368' },
        { lbl: '— F&B', val: '€5,535' },
        { lbl: '— Другое', val: '€1,277' },
        { lbl: 'Себестоимость продаж (COGS)', val: '€8,120', neg: true },
        { lbl: 'Операционные расходы', val: '€15,163', neg: true },
        { lbl: '— Зарплаты', val: '€8,400' },
        { lbl: '— Коммунальные', val: '€2,180' },
        { lbl: '— Хозяйственные', val: '€2,260' },
        { lbl: '— Прочее', val: '€2,323' },
        { lbl: 'Маркетинг', val: '€4,820', neg: true },
        { lbl: 'GOP', val: '€14,477', total: true, deltaPct: '+18%' },
        { lbl: 'GOP margin', val: '34%', total: true, deltaPct: '+2 п.п.' },
      ],
    },
  },
  en: {
    appName: 'SEOVILLAGE',
    tabs: ['Overview', 'Revenue', 'Marketing', 'Booking', 'Traffic', 'Customers'],
    period: {
      lbl: 'Last 30 days',
      opts: ['Today', 'Last 7 days', 'Last 30 days', 'Last 90 days', 'Custom...'],
      options: [
        { value: '7d', label: 'Last 7 days' },
        { value: '30d', label: 'Last 30 days' },
        { value: '90d', label: 'Last 90 days' },
        { value: 'ytd', label: 'Year to date' },
        { value: 'custom', label: 'Custom range...', disabled: true, hint: 'Coming soon' },
      ],
    },
    compare: {
      lbl: 'vs Previous period',
      opts: ['vs Previous period', 'vs Last year', 'vs 90-day average'],
      options: [
        { value: 'prev', label: 'vs Previous period' },
        { value: 'ly', label: 'vs Last year' },
        { value: 'avg90', label: 'vs 90-day average' },
      ],
    },
    updated: 'Updated 2 min ago',
    pulse: {
      inhouse: { lbl: 'In-house', val: '4/5 cabins · 8 guests' },
      arrivals: { lbl: 'Arrivals today', val: '2' },
      departures: { lbl: 'Departures today', val: '1' },
      spa: { lbl: 'SPA bookings today', val: '6 sessions' },
      msg: { lbl: 'New messages', val: '3' },
      drawers: {
        inhouse: {
          title: 'Currently in-house',
          summary: '{cabins} cabins · {guests} guests · {empty} free',
          checkedIn: 'Checked in {date}, {days} days ago',
          departure: '{nights} nights left · Departure {date}',
          free: 'Free',
          nextArrival: 'Next arrival: {date} ({guest})',
          openBooking: 'Open booking →',
          footerCTA: 'Open Booking Analytics →',
        },
        arrivals: {
          title: 'Arrivals today',
          summary: '{arrivals} arrivals · {guests} guests',
          newGuest: 'New guest',
          repeatNTimes: 'Repeat {count}x',
          checklistTitle: 'Preparation checklist:',
          channel: 'Channel',
          nights: '{nights} nights',
          sendWhatsApp: 'Send WhatsApp',
          openBooking: 'Open booking →',
          empty: 'No arrivals today. Next arrival: {date} · {cabin} · {guest}',
          footerCTA: 'All arrivals this week →',
        },
        departures: {
          title: 'Departures today',
          summary: '{departures} departure · {guests} guests',
          deadlineUntil: 'By {time}',
          totalSpent: 'Total spent: €{amount}',
          firstVisit: 'First visit',
          repeatGuest: 'Repeat {count}x',
          nights: '{nights} nights',
          checklistTitle: 'After departure:',
          sendThankYou: 'Send thank-you',
          openBooking: 'Open booking →',
          empty: 'No departures today.',
          footerCTA: 'All departures this week →',
        },
        spa: {
          title: 'SPA today',
          summary: '{sessions} sessions · {guests} guests · {free} slots free',
          freeSlot: '— Free —',
          duration: '{minutes}min',
          summaryLoad: 'Day load: {used}/{total} slots · {pct}%',
          summaryRevenue: 'SPA revenue today: €{amount}',
          summaryFree: 'Free slots: {list}',
          crossSellHint: '💡 {free} slots free — consider cross-sell to guests without bookings ({guests})',
          footerCTA: 'Open SPA calendar in Cloudbeds ↗',
        },
        messages: {
          title: 'New messages',
          summary: '{messages} unanswered · {old} unanswered > 30 min ⚠',
          newContact: 'new contact',
          existingGuest: 'existing guest',
          from: 'From',
          openPlatform: 'Open {platform} →',
          markHandled: 'Mark as handled',
          empty: 'All messages handled 👍',
          footerCTA: 'Open all conversations →',
        },
      },
    },
    hero: {
      revenue: 'Total revenue', rpg: 'Revenue per guest', occupancy: 'Occupancy (nights)', gop: 'GOP margin',
      spend: 'Marketing spend', roas: 'Blended ROAS', cac: 'CAC', direct: 'Direct booking',
    },
    chart: {
      title: 'Revenue trend by stream',
      mix: 'Rooms 62% · SPA 22% · F&B 13% · Other 3%',
      legend: { rooms: 'Rooms', spa: 'SPA', fnb: 'F&B', other: 'Other', padel: 'Padel' },
      padelHint: 'Available after launch',
    },
    heatmap: {
      title: 'Occupancy · next 30 days',
      sum: '87/150 · 58%',
      sidebarHeader: 'NEXT 30 DAYS',
      sidebarRevenue: '€{value} OTB',
      weekTitle: 'Week {n}',
      weekPaceVsAvg: 'Pace: {value} vs avg',
      weekendLabel: 'Weekend',
      todayLabel: 'Today',
      legend: { free: 'free', empty: 'free', booked: 'booked', inhouse: 'in-house', inHouse: 'in-house', arrival: 'arrival/departure', payment: 'payment', blocked: 'blocked' },
      cabin: 'Cabin',
      payment: { paid: 'paid', partial: 'partial', unpaid: 'unpaid', hold: 'hold' },
      daysOfWeek: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
      tooltip: {
        free: 'Free',
        blocked: 'Blocked: {reason}',
        medianADR: 'Day ADR: €{value} (median)',
        dayLoad: 'Cabin load on {date}: {pct}% ({n} of {total})',
        arrivalLabel: 'Arrival: {guest} · {time}',
        departureLabel: 'Departure: {guest} · by {time}',
        channelLabel: 'Channel: {channel}',
        discoveryLabel: 'Discovery: {discovery}',
        paymentLabel: 'Payment: {status}',
        balanceLabel: 'balance €{value}',
        repeatLabel: 'Repeat {n}x',
      },
    },
    pace: {
      title: 'Booking pace · next 60 days',
      otb: 'OTB total: 175 nights',
    },
    loyalty: {
      title: 'Guests & reputation',
      score: '/10',
    },
    bookingsTable: {
      title: 'Bookings for period',
      aggregateBookings: 'bookings',
      aggregateNights: 'nights',
      aggregateRevenue: 'revenue',
      aggregateInHouse: 'in-house today',
      periodGlobal: 'Use global period',
      periodGlobalWithLabel: 'Use global period ({label})',
      today: 'Today',
      tomorrow: 'Tomorrow',
      thisWeek: 'This week',
      previousWeek: 'Previous week',
      thisMonth: 'This month',
      previousMonth: 'Previous month',
      customRange: 'Custom range...',
      customPeriod: 'Custom period',
      resetGlobal: 'Reset to global',
      compact: 'Compact',
      detailed: 'Detailed',
      export: 'Export',
      total: 'Total',
      showAll: 'Show all',
      bookings: 'bookings',
      filters: { status: 'Status', cabin: 'Cabin', channel: 'Channel', country: 'Country', discovery: 'Discovery' },
      clearAll: 'Clear all',
      resetFilters: 'Reset filters',
      activeFilters: 'Active filters',
      all: 'All',
      columns: {
        status: 'Status',
        cabin: 'Cabin',
        guest: 'Guest',
        country: 'Country',
        dates: 'Dates',
        guests: 'Guests',
        channel: 'Channel',
        discovery: 'Discovery',
        bookingDate: 'Booking date',
        leadTime: 'Lead time',
        notes: 'Notes',
        repeatStatus: 'Repeat',
        revenue: 'Revenue',
      },
      statuses: {
        in_house: 'In-house',
        confirmed: 'Confirmed',
        checked_out: 'Checked-out',
        cancelled: 'Cancelled',
        no_show: 'No-show',
      },
      confidence: {
        manual: 'Source was marked manually by the team',
        ga4: 'Source inferred from GA4 / attribution data',
        unknown: 'Source is missing or unknown',
      },
      repeat: 'Repeat',
      nightsShort: 'N',
      daysBefore: 'd before',
      emptyPeriod: 'No bookings in the selected period. Try expanding the period or choosing another date range.',
      emptyFilters: 'No matches for the current filters.',
      discoveryWarning: 'Discovery source is missing for',
      discoveryAdvice: 'of bookings. To improve analytics, mark the source during guest check-in.',
      dismiss: 'Dismiss',
      customModalTitle: 'Custom range',
      customModalSub: 'Choose dates for this block only.',
      from: 'From',
      to: 'To',
      apply: 'Apply',
      cancel: 'Cancel',
      comingSoon: 'Coming soon',
      openCloudbeds: 'Open in Cloudbeds',
      sendWhatsapp: 'Send WhatsApp',
      fullListSoon: 'Full bookings list coming soon',
    },
    drawer: {
      title: 'GOP margin · breakdown',
      period: 'For the last 30 days',
      pl: [
        { lbl: 'Revenue', val: '€42,580', kind: 'main' },
        { lbl: '— Rooms', val: '€26,400' },
        { lbl: '— SPA', val: '€9,368' },
        { lbl: '— F&B', val: '€5,535' },
        { lbl: '— Other', val: '€1,277' },
        { lbl: 'Cost of sales (COGS)', val: '€8,120', neg: true },
        { lbl: 'Operating expenses', val: '€15,163', neg: true },
        { lbl: '— Salaries', val: '€8,400' },
        { lbl: '— Utilities', val: '€2,180' },
        { lbl: '— Housekeeping', val: '€2,260' },
        { lbl: '— Other', val: '€2,323' },
        { lbl: 'Marketing', val: '€4,820', neg: true },
        { lbl: 'GOP', val: '€14,477', total: true, deltaPct: '+18%' },
        { lbl: 'GOP margin', val: '34%', total: true, deltaPct: '+2 pp' },
      ],
    },
  },
};

// Help text for each metric
const HELP_OV2 = {
  ru: {
    pulse: { title: 'Операционный пульс', body: 'Для чего: быстро показывает, что происходит сегодня: кто живет, кто заезжает, кто выезжает, сколько СПА-сессий и сообщений. Как работает: это короткая сводка текущего дня, чтобы менеджер сразу видел точки внимания.' },
    pulseItems: {
      inhouse: { title: 'In-house', body: 'Для чего: показывает текущую загрузку прямо сейчас. Как работает: считает домики и гостей, которые уже заехали и еще не выехали сегодня.' },
      arrivals: { title: 'Заезды сегодня', body: 'Для чего: помогает ресепшену и операционной команде подготовить прием гостей. Как работает: считает брони с датой arrival сегодня.' },
      departures: { title: 'Выезды сегодня', body: 'Для чего: показывает, сколько домиков нужно освободить, проверить и подготовить к следующим гостям. Как работает: считает брони с датой departure сегодня.' },
      spa: { title: 'СПА на сегодня', body: 'Для чего: показывает нагрузку SPA-команды на текущий день. Как работает: считает запланированные процедуры и помогает заранее увидеть перегрузку расписания.' },
      messages: { title: 'Новые сообщения', body: 'Для чего: показывает, где нужна быстрая реакция менеджера. Как работает: считает новые входящие обращения из каналов коммуникации; бейдж подсвечивает рост.' },
    },
    revenue: { title: 'Total revenue', body: 'Для чего: показывает общий объем денег, который принес комплекс за выбранный период. Как работает: суммирует размещение, СПА, F&B и прочую выручку; используется как верхнеуровневый показатель роста.' },
    rpg: { title: 'Revenue per guest', body: 'Для чего: показывает, сколько в среднем приносит один гость. Как работает: общая выручка делится на количество гостей; если показатель растет, значит лучше продаются доп. услуги или повышается средний чек.' },
    occupancy: { title: 'Occupancy (ночи)', body: 'Для чего: показывает, насколько заняты домики в ночах. Как работает: считает проданные ночи из возможных 150 за 30 дней (5 домиков × 30 дней); важно смотреть и процент, и абсолютные ночи.' },
    gop: { title: 'GOP margin', body: 'Для чего: показывает операционную прибыльность бизнеса. Как работает: (выручка − COGS − операционные расходы − маркетинг) ÷ выручка; помогает понять, зарабатываем ли мы после основных затрат.' },
    chart: { title: 'Revenue trend by stream', body: 'Для чего: показывает динамику выручки по направлениям: размещение, СПА, F&B и прочее. Как работает: каждый день раскладывается по потокам; можно отключать легенду, чтобы увидеть вклад отдельного направления.' },
    heatmap: { title: 'Occupancy heatmap', body: 'Для чего: визуально показывает загрузку каждого домика на ближайшие 30 дней. Как работает: строки — домики, столбцы — дни; цвет показывает свободно, бронь или in-house, а клик открывает детализацию.' },
    spend: { title: 'Marketing spend', body: 'Для чего: показывает, сколько потрачено на платный трафик. Как работает: суммирует Google Ads, Meta Ads и TikTok Ads без зарплат маркетинга; нужен для контроля бюджета и расчета эффективности.' },
    roas: { title: 'Blended ROAS', body: 'Для чего: показывает окупаемость рекламных расходов. Как работает: выручка, атрибутированная платному трафику, делится на marketing spend; blended означает общий показатель по всем платным каналам.' },
    cac: { title: 'CAC', body: 'Для чего: показывает стоимость привлечения нового гостя. Как работает: marketing spend делится на число новых гостей; рост CAC подсвечивается красным, потому что привлечение становится дороже.' },
    direct: { title: 'Direct booking', body: 'Для чего: показывает долю прямых бронирований без OTA-комиссий. Как работает: считает брони с сайта, WhatsApp и звонков относительно всех бронирований; рост доли снижает комиссии Booking.com/OTA.' },
    pace: { title: 'Booking pace', body: 'Для чего: показывает, достаточно ли будущих броней на горизонтах 7, 14, 30 и 60 дней. Как работает: сравнивает уже проданные ночи с доступными ночами и средним темпом, чтобы заранее видеть просадки спроса.' },
    paceRows: [
      { title: 'Ближайшие 7 дней', body: 'Для чего: показывает почти операционный риск ближайшей недели. Как работает: считает уже проданные ночи из 35 доступных (5 домиков × 7 дней) и сравнивает с нормальным темпом.' },
      { title: 'Дни 8–14', body: 'Для чего: показывает, насколько заполнена следующая неделя после ближайшей. Как работает: считает OTB-ночи на горизонте 8–14 дней и подсвечивает отставание от среднего.' },
      { title: 'Дни 15–30', body: 'Для чего: помогает заранее понять, нужен ли дополнительный маркетинг на конец месяца. Как работает: сравнивает забронированные ночи с доступным фондом и историческим pace.' },
      { title: 'Дни 31–60', body: 'Для чего: ранний индикатор будущего спроса и необходимости кампаний. Как работает: смотрит дальний горизонт, где просадка бронирований обычно требует заранее усилить продажи.' },
    ],
    loyalty: { title: 'Гости и репутация', body: 'Для чего: показывает качество клиентской базы и сервиса. Как работает: объединяет повторные визиты, средний рейтинг и новые отзывы; помогает понять, возвращаются ли гости и довольны ли они опытом.' },
    repeat: { title: 'Repeat guest rate', body: 'Для чего: показывает долю гостей, которые уже были у нас раньше. Как работает: гости с 2+ визитами делятся на всех гостей периода; высокий показатель снижает зависимость от рекламы.' },
    review: { title: 'Avg review score', body: 'Для чего: показывает качество сервиса глазами гостей. Как работает: усредняет оценки Booking.com, Google и TripAdvisor за последние 90 дней; падение рейтинга быстро влияет на спрос.' },
    newReviews: { title: 'Новых отзывов', body: 'Для чего: показывает, насколько активно гости оставляют обратную связь. Как работает: считает новые отзывы за период; больше свежих отзывов обычно улучшает доверие и видимость на площадках.' },
    bookingsTable: { title: 'Бронирования за период', body: 'Для чего: дает операционный список броней за локально выбранный период. Как работает: фильтрует mock-броней по периоду, статусу, домику, каналу, стране и discovery source; total считает весь отфильтрованный набор.' },
  },
  en: {
    pulse: { title: 'Operational pulse', body: 'Purpose: a fast view of today: in-house guests, arrivals, departures, SPA sessions, and messages. How it works: it summarizes same-day operational signals so managers see immediate attention points.' },
    pulseItems: {
      inhouse: { title: 'In-house', body: 'Purpose: shows current occupancy right now. How it works: counts cabins and guests whose stay has started and has not ended today.' },
      arrivals: { title: 'Arrivals today', body: 'Purpose: helps reception and operations prepare guest check-ins. How it works: counts bookings with arrival date today.' },
      departures: { title: 'Departures today', body: 'Purpose: shows how many cabins need check-out, inspection, and reset. How it works: counts bookings with departure date today.' },
      spa: { title: 'SPA today', body: 'Purpose: shows the day load for the SPA team. How it works: counts scheduled treatments and helps spot overloaded schedules early.' },
      messages: { title: 'New messages', body: 'Purpose: shows where a manager needs to respond quickly. How it works: counts new inbound messages across communication channels; the badge highlights pressure.' },
    },
    revenue: { title: 'Total revenue', body: 'Purpose: shows how much money the property generated in the selected period. How it works: sums rooms, SPA, F&B, and other revenue; use it as the top-level growth signal.' },
    rpg: { title: 'Revenue per guest', body: 'Purpose: shows average revenue per guest. How it works: total revenue divided by guest count; growth usually means stronger upsell, add-ons, or average check.' },
    occupancy: { title: 'Occupancy (nights)', body: 'Purpose: shows how fully the cabins are booked in nights. How it works: sold cabin-nights out of 150 possible for 30 days (5 cabins × 30 days); watch both percent and absolute nights.' },
    gop: { title: 'GOP margin', body: 'Purpose: shows operating profitability. How it works: (revenue − COGS − operating expenses − marketing) divided by revenue; it answers whether the business earns after core costs.' },
    chart: { title: 'Revenue trend by stream', body: 'Purpose: shows revenue movement across rooms, SPA, F&B, and other streams. How it works: each day is split by revenue stream; legend toggles isolate specific streams.' },
    heatmap: { title: 'Occupancy heatmap', body: 'Purpose: gives a visual view of cabin occupancy for the next 30 days. How it works: rows are cabins, columns are days, color marks free, booked, or in-house; clicking opens detail.' },
    spend: { title: 'Marketing spend', body: 'Purpose: shows total paid-traffic cost. How it works: sums Google Ads, Meta Ads, and TikTok Ads, excluding marketing salaries; used for budget control and efficiency metrics.' },
    roas: { title: 'Blended ROAS', body: 'Purpose: shows payback on ad spend. How it works: paid-attributed revenue divided by marketing spend; blended means combined performance across paid channels.' },
    cac: { title: 'CAC', body: 'Purpose: shows the cost to acquire one new guest. How it works: marketing spend divided by new guests; rising CAC is flagged because acquisition is getting more expensive.' },
    direct: { title: 'Direct booking', body: 'Purpose: shows the share of bookings that avoid OTA commission. How it works: website, WhatsApp, and phone bookings divided by total bookings; growth reduces Booking.com/OTA fees.' },
    pace: { title: 'Booking pace', body: 'Purpose: shows whether future demand is healthy across 7, 14, 30, and 60-day horizons. How it works: compares booked nights with available nights and average pace to spot demand gaps early.' },
    paceRows: [
      { title: 'Next 7 days', body: 'Purpose: shows near-term operational risk. How it works: counts booked nights out of 35 available (5 cabins × 7 days) and compares with normal pace.' },
      { title: 'Days 8–14', body: 'Purpose: shows how the following week is filling. How it works: counts OTB nights for days 8–14 and flags gaps versus average pace.' },
      { title: 'Days 15–30', body: 'Purpose: helps decide whether extra marketing is needed later this month. How it works: compares booked nights with available capacity and historical booking pace.' },
      { title: 'Days 31–60', body: 'Purpose: early signal for future demand and campaign planning. How it works: checks the long horizon where booking softness usually means sales should be pushed earlier.' },
    ],
    loyalty: { title: 'Guests & reputation', body: 'Purpose: summarizes guest loyalty and service quality. How it works: combines repeat visits, average review score, and new reviews to show whether guests return and recommend the experience.' },
    repeat: { title: 'Repeat guest rate', body: 'Purpose: shows the share of guests who have stayed before. How it works: guests with 2+ visits divided by all guests in the period; higher repeat lowers dependence on paid acquisition.' },
    review: { title: 'Avg review score', body: 'Purpose: shows service quality from the guest point of view. How it works: averages Booking.com, Google, and TripAdvisor scores for the last 90 days; declines can quickly affect demand.' },
    newReviews: { title: 'New reviews', body: 'Purpose: shows how actively guests leave feedback. How it works: counts new reviews in the period; fresh reviews usually improve trust and marketplace visibility.' },
    bookingsTable: { title: 'Bookings for period', body: 'Purpose: provides an operational list of bookings for the local period. How it works: filters mock bookings by period, status, cabin, channel, country, and discovery source; total summarizes the whole filtered set.' },
  },
};

window.OV2 = OV2;
window.OV2.deriveForPeriod = OV2.deriveForPeriod;
window.I18N_OV2 = I18N_OV2;
window.HELP_OV2 = HELP_OV2;
