/* ============================================================ EATME PORTÁL – DASHBOARD SERVICE Optimalizovaná verze Cíle: - zachovat stejné veřejné funkce - zrychlit opakované čtení stabilnějších tabulek - necacheovat živé SHIFTS - předindexovat PAY_RATES podle zaměstnance ============================================================ */ /* ============================================================ KRÁTKODOBÁ CACHE PRO STABILNĚJŠÍ TABULKY SHIFTS záměrně necachujeme, protože dashboard má ukazovat příchody/odchody co nejčerstvěji. ============================================================ */ const DASHBOARD_CACHE_TTL_SECONDS = 20; /* ============================================================ CACHE V RÁMCI JEDNOHO APPS SCRIPT BĚHU Zapíná se pouze pro agregované read-only requesty. Díky tomu může více služeb ve stejném requestu použít stejná data bez dalšího čtení Google Sheets. ============================================================ */ let REQUEST_ROW_CACHE_ENABLED = false; let REQUEST_ROW_CACHE = {}; function beginRequestRowCache_() { REQUEST_ROW_CACHE_ENABLED = true; REQUEST_ROW_CACHE = {}; } function endRequestRowCache_() { REQUEST_ROW_CACHE_ENABLED = false; REQUEST_ROW_CACHE = {}; } function clearRequestRowCache_( sheetName ) { if ( !sheetName ) { REQUEST_ROW_CACHE = {}; return; } delete REQUEST_ROW_CACHE[ String( sheetName ) ]; } /* ============================================================ ADMIN DASHBOARD ============================================================ */ function getAdminDashboard(token) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); /* * EMPLOYEES, PAY_RATES a žádosti se nemění každou sekundu, * proto mohou mít krátkou cache. * * SHIFTS vždy čteme čerstvě. */ const employees = displayRowsCached_( CFG.SHEETS.EMPLOYEES, DASHBOARD_CACHE_TTL_SECONDS ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const payRates = displayRowsCached_( CFG.SHEETS.PAY_RATES, DASHBOARD_CACHE_TTL_SECONDS ); const changeRequests = displayRowsCached_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS, 10 ); const requests = displayRowsCached_( CFG.SHEETS.REQUESTS, 10 ); /* ========================================= AKTIVNÍ ZAMĚSTNANCI ========================================= */ const activeEmployees = employees.filter( function(employee) { return isActiveValue_( employee.active ); } ); /* ========================================= MAPA ZAMĚSTNANCŮ PODLE ID ========================================= */ const employeeMap = {}; employees.forEach( function(employee) { employeeMap[ String( employee.employee_id ) ] = employee; } ); /* ========================================= INDEX SAZEB PODLE ZAMĚSTNANCE Původní verze pro každou směnu znovu filtrovala celý PAY_RATES. Tady to připravíme jednou. ========================================= */ const rateIndex = buildRateIndex_( payRates ); /* ========================================= AKTUÁLNÍ MĚSÍC ========================================= */ const now = new Date(); const currentPeriod = Utilities.formatDate( now, CFG.TZ, 'yyyy-MM' ); /* ========================================= OTEVŘENÉ SMĚNY ========================================= */ const openShifts = []; const monthShifts = []; /* * Jediný průchod přes SHIFTS. * Původně se tabulka filtrovala vícekrát. */ shifts.forEach( function(shift) { const status = String( shift.status || '' ).trim(); if ( status === 'OPEN' ) { openShifts.push( shift ); } const date = parseSheetDate_( shift.clock_in ); if ( !date ) { return; } const shiftPeriod = Utilities.formatDate( date, CFG.TZ, 'yyyy-MM' ); if ( shiftPeriod === currentPeriod ) { monthShifts.push( shift ); } } ); /* ========================================= SOUČTY ========================================= */ let totalWorkedMinutes = 0; let estimatedPayroll = 0; monthShifts.forEach( function(shift) { const workedMinutes = numberFromSheet_( shift.worked_minutes ); totalWorkedMinutes += workedMinutes; if ( workedMinutes <= 0 ) { return; } const shiftDate = parseSheetDate_( shift.clock_in ); if ( !shiftDate ) { return; } const hourlyRate = getIndexedRateForDate_( rateIndex, shift.employee_id, shiftDate ); estimatedPayroll += ( workedMinutes / 60 ) * hourlyRate; } ); /* ========================================= KDO JE PRÁVĚ V PRÁCI ========================================= */ const nowMs = Date.now(); const openPeople = openShifts.map( function(shift) { const employee = employeeMap[ String( shift.employee_id ) ]; const clockIn = parseSheetDate_( shift.clock_in ); let currentMinutes = 0; if ( clockIn ) { currentMinutes = Math.max( 0, Math.floor( ( nowMs - clockIn.getTime() ) / 60000 ) ); } let employeeName = String( shift.employee_id || '' ); if ( employee ) { employeeName = ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim(); } return { shift_id: String( shift.shift_id || '' ), employee_id: String( shift.employee_id || '' ), name: employeeName, location_id: String( shift.location_id || '' ), clock_in: clockIn ? clockIn.toISOString() : '', current_minutes: currentMinutes }; } ); /* ========================================= ČEKAJÍCÍ OPRAVY ========================================= */ let pendingChangeRequests = 0; changeRequests.forEach( function(request) { if ( String( request.status || '' ).trim() === 'PENDING' ) { pendingChangeRequests++; } } ); /* ========================================= OSTATNÍ ČEKAJÍCÍ ŽÁDOSTI ========================================= */ let pendingRequests = 0; requests.forEach( function(request) { if ( String( request.status || '' ).trim() === 'PENDING' ) { pendingRequests++; } } ); /* ========================================= PODEZŘELE DLOUHÉ SMĚNY ========================================= */ let staleOpenShifts = 0; openShifts.forEach( function(shift) { const clockIn = parseSheetDate_( shift.clock_in ); if ( !clockIn ) { return; } const hours = ( nowMs - clockIn.getTime() ) / 3600000; if ( hours > 14 ) { staleOpenShifts++; } } ); /* ========================================= VÝSLEDEK PRO FRONTEND ========================================= */ return { current_period: currentPeriod, stats: { active_employees: activeEmployees.length, open_shifts: openShifts.length, worked_minutes: Math.round( totalWorkedMinutes ), estimated_payroll: Math.round( estimatedPayroll ), pending_change_requests: pendingChangeRequests, pending_requests: pendingRequests, stale_open_shifts: staleOpenShifts }, open_people: openPeople }; } /* ============================================================ ČTENÍ TABULKY JAKO TEXTŮ Tato funkce zůstává bez cache, protože ji používá celý projekt. Tím minimalizujeme riziko, že nějaký zápis nebude okamžitě vidět. ============================================================ */ function displayRows_(sheetName) { const cacheKey = String( sheetName ); if ( REQUEST_ROW_CACHE_ENABLED && Object.prototype.hasOwnProperty.call( REQUEST_ROW_CACHE, cacheKey ) ) { return REQUEST_ROW_CACHE[ cacheKey ]; } const sheet = sh_( sheetName ); const lastRow = sheet.getLastRow(); const lastColumn = sheet.getLastColumn(); if ( lastRow < 2 || lastColumn < 1 ) { if ( REQUEST_ROW_CACHE_ENABLED ) { REQUEST_ROW_CACHE[ cacheKey ] = []; } return []; } const values = sheet .getRange( 1, 1, lastRow, lastColumn ) .getDisplayValues(); const headers = values[0] .map( function(header) { return String( header ).trim(); } ); const result = []; for ( let rowIndex = 1; rowIndex < values.length; rowIndex++ ) { const row = values[ rowIndex ]; let hasValue = false; for ( let i = 0; i < row.length; i++ ) { if ( String( row[i] ).trim() !== '' ) { hasValue = true; break; } } if ( !hasValue ) { continue; } const object = {}; for ( let columnIndex = 0; columnIndex < headers.length; columnIndex++ ) { const header = headers[ columnIndex ]; if ( !header ) { continue; } object[ header ] = row[ columnIndex ]; } result.push( object ); } if ( REQUEST_ROW_CACHE_ENABLED ) { REQUEST_ROW_CACHE[ cacheKey ] = result; } return result; } /* ============================================================ KRÁTKODOBĚ CACHOVANÉ ČTENÍ Používá se pouze tam, kde malé zpoždění nevadí. ============================================================ */ function displayRowsCached_( sheetName, ttlSeconds ) { ttlSeconds = Number( ttlSeconds || DASHBOARD_CACHE_TTL_SECONDS ); const cache = CacheService .getScriptCache(); const key = 'DISPLAY_ROWS_V1_' + String( sheetName ); try { const cached = cache.get( key ); if ( cached ) { return JSON.parse( cached ); } } catch(error) { /* * Cache nesmí nikdy rozbít aplikaci. * Při chybě pokračujeme normálním čtením. */ } const rows = displayRows_( sheetName ); /* * CacheService má limit velikosti jedné hodnoty. * Když je tabulka moc velká, zápis jen přeskočíme. */ try { const json = JSON.stringify( rows ); if ( json.length < 90000 ) { cache.put( key, json, ttlSeconds ); } } catch(error) { /* * Opět ignorujeme pouze chybu cache. */ } return rows; } /* ============================================================ RUČNÍ SMAZÁNÍ DASHBOARD CACHE Lze spustit ručně při testování. ============================================================ */ function clearDashboardCache() { const cache = CacheService .getScriptCache(); [ CFG.SHEETS.EMPLOYEES, CFG.SHEETS.PAY_RATES, CFG.SHEETS.SHIFT_CHANGE_REQUESTS, CFG.SHEETS.REQUESTS ] .forEach( function(sheetName) { cache.remove( 'DISPLAY_ROWS_V1_' + String( sheetName ) ); } ); Logger.log( 'Dashboard cache cleared' ); } /* ============================================================ INDEX SAZEB ============================================================ */ function buildRateIndex_(rates) { const index = {}; rates.forEach( function(rate) { const employeeId = String( rate.employee_id || '' ); if ( !employeeId ) { return; } if ( !index[ employeeId ] ) { index[ employeeId ] = []; } const fromDate = parseSheetDate_( rate.valid_from ); const toDate = parseSheetDate_( rate.valid_to ); index[ employeeId ].push( { from: fromDate ? Utilities.formatDate( fromDate, CFG.TZ, 'yyyy-MM-dd' ) : '0000-00-00', to: toDate ? Utilities.formatDate( toDate, CFG.TZ, 'yyyy-MM-dd' ) : '9999-12-31', rate: numberFromSheet_( rate.hourly_rate ) } ); } ); Object.keys( index ) .forEach( function(employeeId) { index[ employeeId ].sort( function(a,b) { return b.from.localeCompare( a.from ); } ); } ); return index; } /* ============================================================ SAZBA Z INDEXU ============================================================ */ function getIndexedRateForDate_( rateIndex, employeeId, targetDate ) { if ( !targetDate ) { return 0; } const employeeRates = rateIndex[ String( employeeId ) ] || []; if ( !employeeRates.length ) { return 0; } const target = Utilities.formatDate( targetDate, CFG.TZ, 'yyyy-MM-dd' ); for ( let i = 0; i < employeeRates.length; i++ ) { const rate = employeeRates[ i ]; if ( rate.from <= target && rate.to >= target ) { return rate.rate; } } return 0; } /* ============================================================ AKTIVNÍ HODNOTA ============================================================ */ function isActiveValue_(value) { const text = String( value || '' ) .trim() .toUpperCase(); return ( text === 'TRUE' || text === 'ANO' || text === 'YES' || text === '1' ); } /* ============================================================ ČÍSLO Z GOOGLE SHEETS ============================================================ */ function numberFromSheet_(value) { let text = String( value || '0' ) .trim() .replace( /\s/g, '' ) .replace( ',', '.' ); text = text.replace( /[^0-9.\-]/g, '' ); const number = Number( text ); return isNaN( number ) ? 0 : number; } /* ============================================================ PARSOVÁNÍ DATUMU ============================================================ */ function parseSheetDate_(value) { const text = String( value || '' ).trim(); if ( !text ) { return null; } let match; /* ========================================= ISO FORMÁT 2026-08-11 2026-08-11 23:44:56 ========================================= */ match = text.match( /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } /* ========================================= ČESKÝ FORMÁT 11.8.2026 11.8.2026 23:44:56 ========================================= */ match = text.match( /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[3] ), Number( match[2] ) - 1, Number( match[1] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } /* ========================================= GOOGLE / US FORMÁT 8/11/2026 8/11/2026 23:44:56 měsíc / den / rok ========================================= */ match = text.match( /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[3] ), Number( match[1] ) - 1, Number( match[2] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } /* ========================================= POSLEDNÍ POKUS ========================================= */ const date = new Date( text ); if ( isNaN( date.getTime() ) ) { return null; } return date; } /* ============================================================ PŮVODNÍ VEŘEJNÁ FUNKCE PRO SAZBU Zachována kvůli kompatibilitě s ostatními moduly. ============================================================ */ function getDisplayRateForDate_( rates, employeeId, targetDate ) { if ( !targetDate ) { return 0; } const rateIndex = buildRateIndex_( rates ); return getIndexedRateForDate_( rateIndex, employeeId, targetDate ); } /* ============================================================ DIAGNOSTIKA DATABÁZE ============================================================ */ function testDashboardDatabase() { const started = Date.now(); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const afterEmployees = Date.now(); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const afterShifts = Date.now(); Logger.log( 'EMPLOYEES: ' + employees.length + ' | ' + ( afterEmployees - started ) + ' ms' ); Logger.log( 'SHIFTS: ' + shifts.length + ' | ' + ( afterShifts - afterEmployees ) + ' ms' ); Logger.log( 'TOTAL: ' + ( afterShifts - started ) + ' ms' ); return { employees: employees.length, shifts: shifts.length, employees_ms: afterEmployees - started, shifts_ms: afterShifts - afterEmployees, total_ms: afterShifts - started }; } /* ============================================================ DIAGNOSTIKA ADMIN DASHBOARDU ============================================================ */ function testAdminDashboardPerformance() { const started = Date.now(); /* * Test vyžaduje validní token při volání přes frontend, * proto zde měříme pouze datovou část. */ const employees = displayRowsCached_( CFG.SHEETS.EMPLOYEES, DASHBOARD_CACHE_TTL_SECONDS ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const payRates = displayRowsCached_( CFG.SHEETS.PAY_RATES, DASHBOARD_CACHE_TTL_SECONDS ); const rateIndex = buildRateIndex_( payRates ); const finished = Date.now(); Logger.log( 'EMPLOYEES: ' + employees.length ); Logger.log( 'SHIFTS: ' + shifts.length ); Logger.log( 'PAY_RATES: ' + payRates.length ); Logger.log( 'RATE INDEX EMPLOYEES: ' + Object.keys( rateIndex ).length ); Logger.log( 'TOTAL: ' + ( finished - started ) + ' ms' ); return { total_ms: finished - started, employees: employees.length, shifts: shifts.length, pay_rates: payRates.length }; }