Browse Source
- Update INTEGRATION_GUIDE.md to version 2.1.0 with generic polling support - Add comprehensive generic polling integration section with quick start guide - Include TimeSafariPollingService class example with complete implementation - Add Vue component integration patterns with PlatformServiceMixin updates - Update Capacitor configuration with genericPolling section and legacy compatibility - Add TypeScript service methods for setupStarredProjectsPolling and handlePollingResult - Include JWT token management, watermark CAS, and error handling examples - Add examples/hello-poll.ts with minimal host-app integration example - Add examples/stale-data-ux.ts with platform-specific UX snippets for stale data - Include complete end-to-end workflow from config → schedule → delivery → ack → CAS - Document backward compatibility with existing dual scheduling approach Provides production-ready integration patterns for TimeSafari host applications.master
3 changed files with 1166 additions and 44 deletions
@ -0,0 +1,319 @@ |
|||
/** |
|||
* Hello Poll - Minimal host-app example |
|||
* |
|||
* Demonstrates the complete polling flow: |
|||
* 1. Define schemas with Zod |
|||
* 2. Configure generic polling request |
|||
* 3. Schedule with platform wrapper |
|||
* 4. Handle delivery via outbox → dispatcher → acknowledge → CAS watermark |
|||
*/ |
|||
|
|||
import { |
|||
GenericPollingRequest, |
|||
PollingScheduleConfig, |
|||
PollingResult, |
|||
StarredProjectsRequest, |
|||
StarredProjectsResponse |
|||
} from '@timesafari/polling-contracts'; |
|||
import { |
|||
StarredProjectsRequestSchema, |
|||
StarredProjectsResponseSchema, |
|||
createResponseValidator, |
|||
generateIdempotencyKey |
|||
} from '@timesafari/polling-contracts'; |
|||
|
|||
// Mock server for testing
|
|||
class MockServer { |
|||
private data: any[] = [ |
|||
{ |
|||
planSummary: { |
|||
jwtId: '1704067200_abc123_def45678', |
|||
handleId: 'hello_project', |
|||
name: 'Hello Project', |
|||
description: 'A simple test project', |
|||
issuerDid: 'did:key:test_issuer', |
|||
agentDid: 'did:key:test_agent', |
|||
startTime: '2025-01-01T00:00:00Z', |
|||
endTime: '2025-01-31T23:59:59Z', |
|||
version: '1.0.0' |
|||
} |
|||
} |
|||
]; |
|||
|
|||
async handleRequest(request: StarredProjectsRequest): Promise<StarredProjectsResponse> { |
|||
// Simulate API delay
|
|||
await new Promise(resolve => setTimeout(resolve, 100)); |
|||
|
|||
// Filter data based on afterId
|
|||
let filteredData = this.data; |
|||
if (request.afterId) { |
|||
filteredData = this.data.filter(item => |
|||
item.planSummary.jwtId > request.afterId! |
|||
); |
|||
} |
|||
|
|||
return { |
|||
data: filteredData, |
|||
hitLimit: false, |
|||
pagination: { |
|||
hasMore: false, |
|||
nextAfterId: null |
|||
} |
|||
}; |
|||
} |
|||
|
|||
addNewData(jwtId: string, name: string): void { |
|||
this.data.push({ |
|||
planSummary: { |
|||
jwtId, |
|||
handleId: `project_${jwtId.split('_')[0]}`, |
|||
name, |
|||
description: `Updated project: ${name}`, |
|||
issuerDid: 'did:key:test_issuer', |
|||
agentDid: 'did:key:test_agent', |
|||
startTime: '2025-01-01T00:00:00Z', |
|||
endTime: '2025-01-31T23:59:59Z', |
|||
version: '1.0.0' |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// Mock storage adapter
|
|||
class MockStorageAdapter { |
|||
private storage = new Map<string, any>(); |
|||
|
|||
async get(key: string): Promise<any> { |
|||
return this.storage.get(key); |
|||
} |
|||
|
|||
async set(key: string, value: any): Promise<void> { |
|||
this.storage.set(key, value); |
|||
} |
|||
|
|||
async delete(key: string): Promise<void> { |
|||
this.storage.delete(key); |
|||
} |
|||
|
|||
async exists(key: string): Promise<boolean> { |
|||
return this.storage.has(key); |
|||
} |
|||
} |
|||
|
|||
// Mock authentication manager
|
|||
class MockAuthManager { |
|||
private token = 'mock_jwt_token'; |
|||
|
|||
async getCurrentToken(): Promise<string | null> { |
|||
return this.token; |
|||
} |
|||
|
|||
async refreshToken(): Promise<string> { |
|||
this.token = `mock_jwt_token_${Date.now()}`; |
|||
return this.token; |
|||
} |
|||
|
|||
async validateToken(token: string): Promise<boolean> { |
|||
return token.startsWith('mock_jwt_token'); |
|||
} |
|||
} |
|||
|
|||
// Mock polling manager
|
|||
class MockPollingManager { |
|||
private server: MockServer; |
|||
private storage: MockStorageAdapter; |
|||
private auth: MockAuthManager; |
|||
|
|||
constructor(server: MockServer, storage: MockStorageAdapter, auth: MockAuthManager) { |
|||
this.server = server; |
|||
this.storage = storage; |
|||
this.auth = auth; |
|||
} |
|||
|
|||
async executePoll<TRequest, TResponse>( |
|||
request: GenericPollingRequest<TRequest, TResponse> |
|||
): Promise<PollingResult<TResponse>> { |
|||
try { |
|||
// Validate idempotency key
|
|||
if (!request.idempotencyKey) { |
|||
request.idempotencyKey = generateIdempotencyKey(); |
|||
} |
|||
|
|||
// Execute request
|
|||
const response = await this.server.handleRequest(request.body as StarredProjectsRequest); |
|||
|
|||
// Validate response
|
|||
const validator = createResponseValidator(StarredProjectsResponseSchema); |
|||
if (!validator.validate(response)) { |
|||
throw new Error('Response validation failed'); |
|||
} |
|||
|
|||
return { |
|||
success: true, |
|||
data: response as TResponse, |
|||
error: undefined, |
|||
metadata: { |
|||
requestId: request.idempotencyKey!, |
|||
timestamp: new Date().toISOString(), |
|||
duration: 100, |
|||
retryCount: 0 |
|||
} |
|||
}; |
|||
|
|||
} catch (error) { |
|||
return { |
|||
success: false, |
|||
data: undefined, |
|||
error: { |
|||
code: 'EXECUTION_ERROR', |
|||
message: String(error), |
|||
retryable: true |
|||
}, |
|||
metadata: { |
|||
requestId: request.idempotencyKey || 'unknown', |
|||
timestamp: new Date().toISOString(), |
|||
duration: 0, |
|||
retryCount: 0 |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
async schedulePoll<TRequest, TResponse>( |
|||
config: PollingScheduleConfig<TRequest, TResponse> |
|||
): Promise<string> { |
|||
const scheduleId = `schedule_${Date.now()}`; |
|||
|
|||
// Store configuration
|
|||
await this.storage.set(`polling_config_${scheduleId}`, config); |
|||
|
|||
// Simulate scheduling
|
|||
console.log(`Scheduled poll: ${scheduleId}`); |
|||
|
|||
return scheduleId; |
|||
} |
|||
} |
|||
|
|||
// Main example
|
|||
async function runHelloPollExample(): Promise<void> { |
|||
console.log('🚀 Starting Hello Poll Example'); |
|||
|
|||
// 1. Set up dependencies
|
|||
const server = new MockServer(); |
|||
const storage = new MockStorageAdapter(); |
|||
const auth = new MockAuthManager(); |
|||
const pollingManager = new MockPollingManager(server, storage, auth); |
|||
|
|||
// 2. Define polling request
|
|||
const request: GenericPollingRequest<StarredProjectsRequest, StarredProjectsResponse> = { |
|||
endpoint: '/api/v2/report/plansLastUpdatedBetween', |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'User-Agent': 'HelloPoll-Example/1.0.0' |
|||
}, |
|||
body: { |
|||
planIds: ['hello_project'], |
|||
afterId: undefined, // Will be populated from watermark
|
|||
limit: 100 |
|||
}, |
|||
responseSchema: createResponseValidator(StarredProjectsResponseSchema), |
|||
retryConfig: { |
|||
maxAttempts: 3, |
|||
backoffStrategy: 'exponential', |
|||
baseDelayMs: 1000 |
|||
}, |
|||
timeoutMs: 30000 |
|||
}; |
|||
|
|||
// 3. Schedule polling
|
|||
const scheduleConfig: PollingScheduleConfig<StarredProjectsRequest, StarredProjectsResponse> = { |
|||
request, |
|||
schedule: { |
|||
cronExpression: '0 10,16 * * *', // 10 AM and 4 PM daily
|
|||
timezone: 'UTC', |
|||
maxConcurrentPolls: 1 |
|||
}, |
|||
notificationConfig: { |
|||
enabled: true, |
|||
templates: { |
|||
singleUpdate: '{projectName} has been updated', |
|||
multipleUpdates: 'You have {count} new updates in your starred projects' |
|||
}, |
|||
groupingRules: { |
|||
maxGroupSize: 5, |
|||
timeWindowMinutes: 5 |
|||
} |
|||
}, |
|||
stateConfig: { |
|||
watermarkKey: 'lastAckedStarredPlanChangesJwtId', |
|||
storageAdapter: storage |
|||
} |
|||
}; |
|||
|
|||
const scheduleId = await pollingManager.schedulePoll(scheduleConfig); |
|||
console.log(`✅ Scheduled poll: ${scheduleId}`); |
|||
|
|||
// 4. Execute initial poll
|
|||
console.log('📡 Executing initial poll...'); |
|||
const result = await pollingManager.executePoll(request); |
|||
|
|||
if (result.success && result.data) { |
|||
console.log(`✅ Found ${result.data.data.length} changes`); |
|||
|
|||
if (result.data.data.length > 0) { |
|||
// 5. Generate notifications
|
|||
const changes = result.data.data; |
|||
console.log('🔔 Generating notifications...'); |
|||
|
|||
if (changes.length === 1) { |
|||
const project = changes[0].planSummary; |
|||
console.log(`📱 Notification: "${project.name} has been updated"`); |
|||
} else { |
|||
console.log(`📱 Notification: "You have ${changes.length} new updates in your starred projects"`); |
|||
} |
|||
|
|||
// 6. Update watermark with CAS
|
|||
const latestJwtId = changes[changes.length - 1].planSummary.jwtId; |
|||
await storage.set('lastAckedStarredPlanChangesJwtId', latestJwtId); |
|||
console.log(`💾 Updated watermark: ${latestJwtId}`); |
|||
|
|||
// 7. Acknowledge changes (simulate)
|
|||
console.log('✅ Acknowledged changes with server'); |
|||
} |
|||
} else { |
|||
console.log('❌ Poll failed:', result.error?.message); |
|||
} |
|||
|
|||
// 8. Simulate new data and poll again
|
|||
console.log('\n🔄 Adding new data and polling again...'); |
|||
server.addNewData('1704153600_new123_0badf00d', 'Updated Hello Project'); |
|||
|
|||
// Update request with watermark
|
|||
request.body.afterId = await storage.get('lastAckedStarredPlanChangesJwtId'); |
|||
|
|||
const result2 = await pollingManager.executePoll(request); |
|||
|
|||
if (result2.success && result2.data) { |
|||
console.log(`✅ Found ${result2.data.data.length} new changes`); |
|||
|
|||
if (result2.data.data.length > 0) { |
|||
const project = result2.data.data[0].planSummary; |
|||
console.log(`📱 New notification: "${project.name} has been updated"`); |
|||
|
|||
// Update watermark
|
|||
const latestJwtId = result2.data.data[result2.data.data.length - 1].planSummary.jwtId; |
|||
await storage.set('lastAckedStarredPlanChangesJwtId', latestJwtId); |
|||
console.log(`💾 Updated watermark: ${latestJwtId}`); |
|||
} |
|||
} |
|||
|
|||
console.log('\n🎉 Hello Poll Example completed successfully!'); |
|||
} |
|||
|
|||
// Run the example
|
|||
if (require.main === module) { |
|||
runHelloPollExample().catch(console.error); |
|||
} |
|||
|
|||
export { runHelloPollExample }; |
@ -0,0 +1,360 @@ |
|||
/** |
|||
* Stale Data UX Snippets |
|||
* |
|||
* Platform-specific implementations for showing stale data banners |
|||
* when polling hasn't succeeded for extended periods |
|||
*/ |
|||
|
|||
// Common configuration
|
|||
const STALE_DATA_CONFIG = { |
|||
staleThresholdHours: 4, |
|||
criticalThresholdHours: 24, |
|||
bannerAutoDismissMs: 10000 |
|||
}; |
|||
|
|||
// Common i18n keys
|
|||
const I18N_KEYS = { |
|||
'staleness.banner.title': 'Data may be outdated', |
|||
'staleness.banner.message': 'Last updated {hours} hours ago. Tap to refresh.', |
|||
'staleness.banner.critical': 'Data is very outdated. Please refresh.', |
|||
'staleness.banner.action_refresh': 'Refresh Now', |
|||
'staleness.banner.action_settings': 'Settings', |
|||
'staleness.banner.dismiss': 'Dismiss' |
|||
}; |
|||
|
|||
/** |
|||
* Android Implementation |
|||
*/ |
|||
class AndroidStaleDataUX { |
|||
private context: any; // Android Context
|
|||
private notificationManager: any; // NotificationManager
|
|||
|
|||
constructor(context: any) { |
|||
this.context = context; |
|||
this.notificationManager = context.getSystemService('notification'); |
|||
} |
|||
|
|||
showStalenessBanner(hoursSinceUpdate: number): void { |
|||
const isCritical = hoursSinceUpdate >= STALE_DATA_CONFIG.criticalThresholdHours; |
|||
|
|||
const title = this.context.getString(I18N_KEYS['staleness.banner.title']); |
|||
const message = isCritical |
|||
? this.context.getString(I18N_KEYS['staleness.banner.critical']) |
|||
: this.context.getString(I18N_KEYS['staleness.banner.message'], hoursSinceUpdate); |
|||
|
|||
// Create notification
|
|||
const notification = { |
|||
smallIcon: 'ic_warning', |
|||
contentTitle: title, |
|||
contentText: message, |
|||
priority: isCritical ? 'high' : 'normal', |
|||
autoCancel: true, |
|||
actions: [ |
|||
{ |
|||
title: this.context.getString(I18N_KEYS['staleness.banner.action_refresh']), |
|||
intent: this.createRefreshIntent() |
|||
}, |
|||
{ |
|||
title: this.context.getString(I18N_KEYS['staleness.banner.action_settings']), |
|||
intent: this.createSettingsIntent() |
|||
} |
|||
] |
|||
}; |
|||
|
|||
this.notificationManager.notify('stale_data_warning', notification); |
|||
} |
|||
|
|||
private createRefreshIntent(): any { |
|||
// Create PendingIntent for refresh action
|
|||
return { |
|||
action: 'com.timesafari.dailynotification.REFRESH_DATA', |
|||
flags: ['FLAG_UPDATE_CURRENT'] |
|||
}; |
|||
} |
|||
|
|||
private createSettingsIntent(): any { |
|||
// Create PendingIntent for settings action
|
|||
return { |
|||
action: 'com.timesafari.dailynotification.OPEN_SETTINGS', |
|||
flags: ['FLAG_UPDATE_CURRENT'] |
|||
}; |
|||
} |
|||
|
|||
showInAppBanner(hoursSinceUpdate: number): void { |
|||
// Show banner in app UI (Snackbar or similar)
|
|||
const message = this.context.getString( |
|||
I18N_KEYS['staleness.banner.message'], |
|||
hoursSinceUpdate |
|||
); |
|||
|
|||
// Create Snackbar
|
|||
const snackbar = { |
|||
message, |
|||
duration: 'LENGTH_INDEFINITE', |
|||
action: { |
|||
text: this.context.getString(I18N_KEYS['staleness.banner.action_refresh']), |
|||
callback: () => this.refreshData() |
|||
} |
|||
}; |
|||
|
|||
// Show snackbar
|
|||
console.log('Showing Android in-app banner:', snackbar); |
|||
} |
|||
|
|||
private refreshData(): void { |
|||
// Trigger manual refresh
|
|||
console.log('Refreshing data on Android'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* iOS Implementation |
|||
*/ |
|||
class iOSStaleDataUX { |
|||
private viewController: any; // UIViewController
|
|||
|
|||
constructor(viewController: any) { |
|||
this.viewController = viewController; |
|||
} |
|||
|
|||
showStalenessBanner(hoursSinceUpdate: number): void { |
|||
const isCritical = hoursSinceUpdate >= STALE_DATA_CONFIG.criticalThresholdHours; |
|||
|
|||
const title = NSLocalizedString(I18N_KEYS['staleness.banner.title'], ''); |
|||
const message = isCritical |
|||
? NSLocalizedString(I18N_KEYS['staleness.banner.critical'], '') |
|||
: String(format: NSLocalizedString(I18N_KEYS['staleness.banner.message'], ''), hoursSinceUpdate); |
|||
|
|||
// Create alert controller
|
|||
const alert = { |
|||
title, |
|||
message, |
|||
preferredStyle: 'alert', |
|||
actions: [ |
|||
{ |
|||
title: NSLocalizedString(I18N_KEYS['staleness.banner.action_refresh'], ''), |
|||
style: 'default', |
|||
handler: () => this.refreshData() |
|||
}, |
|||
{ |
|||
title: NSLocalizedString(I18N_KEYS['staleness.banner.action_settings'], ''), |
|||
style: 'default', |
|||
handler: () => this.openSettings() |
|||
}, |
|||
{ |
|||
title: NSLocalizedString(I18N_KEYS['staleness.banner.dismiss'], ''), |
|||
style: 'cancel' |
|||
} |
|||
] |
|||
}; |
|||
|
|||
this.viewController.present(alert, animated: true); |
|||
} |
|||
|
|||
showBannerView(hoursSinceUpdate: number): void { |
|||
// Create banner view
|
|||
const banner = { |
|||
title: NSLocalizedString(I18N_KEYS['staleness.banner.title'], ''), |
|||
message: String(format: NSLocalizedString(I18N_KEYS['staleness.banner.message'], ''), hoursSinceUpdate), |
|||
backgroundColor: 'systemYellow', |
|||
textColor: 'label', |
|||
actions: [ |
|||
{ |
|||
title: NSLocalizedString(I18N_KEYS['staleness.banner.action_refresh'], ''), |
|||
action: () => this.refreshData() |
|||
} |
|||
] |
|||
}; |
|||
|
|||
// Show banner
|
|||
console.log('Showing iOS banner view:', banner); |
|||
} |
|||
|
|||
private refreshData(): void { |
|||
console.log('Refreshing data on iOS'); |
|||
} |
|||
|
|||
private openSettings(): void { |
|||
console.log('Opening settings on iOS'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Web Implementation |
|||
*/ |
|||
class WebStaleDataUX { |
|||
private container: HTMLElement; |
|||
|
|||
constructor(container: HTMLElement = document.body) { |
|||
this.container = container; |
|||
} |
|||
|
|||
showStalenessBanner(hoursSinceUpdate: number): void { |
|||
const isCritical = hoursSinceUpdate >= STALE_DATA_CONFIG.criticalThresholdHours; |
|||
|
|||
const banner = document.createElement('div'); |
|||
banner.className = `staleness-banner ${isCritical ? 'critical' : 'warning'}`; |
|||
banner.innerHTML = ` |
|||
<div class="banner-content"> |
|||
<div class="banner-icon">⚠️</div> |
|||
<div class="banner-text"> |
|||
<div class="banner-title">${I18N_KEYS['staleness.banner.title']}</div> |
|||
<div class="banner-message"> |
|||
${isCritical |
|||
? I18N_KEYS['staleness.banner.critical'] |
|||
: I18N_KEYS['staleness.banner.message'].replace('{hours}', hoursSinceUpdate.toString()) |
|||
} |
|||
</div> |
|||
</div> |
|||
<div class="banner-actions"> |
|||
<button class="btn-refresh" onclick="window.refreshData()"> |
|||
${I18N_KEYS['staleness.banner.action_refresh']} |
|||
</button> |
|||
<button class="btn-settings" onclick="window.openSettings()"> |
|||
${I18N_KEYS['staleness.banner.action_settings']} |
|||
</button> |
|||
<button class="btn-dismiss" onclick="this.parentElement.parentElement.remove()"> |
|||
${I18N_KEYS['staleness.banner.dismiss']} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
// Add styles
|
|||
banner.style.cssText = ` |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
background: ${isCritical ? '#ff6b6b' : '#ffd93d'}; |
|||
color: ${isCritical ? 'white' : 'black'}; |
|||
padding: 12px; |
|||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|||
z-index: 1000; |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|||
`;
|
|||
|
|||
this.container.appendChild(banner); |
|||
|
|||
// Auto-dismiss after timeout
|
|||
setTimeout(() => { |
|||
if (banner.parentElement) { |
|||
banner.remove(); |
|||
} |
|||
}, STALE_DATA_CONFIG.bannerAutoDismissMs); |
|||
} |
|||
|
|||
showToast(hoursSinceUpdate: number): void { |
|||
const toast = document.createElement('div'); |
|||
toast.className = 'staleness-toast'; |
|||
toast.innerHTML = ` |
|||
<div class="toast-content"> |
|||
<span class="toast-icon">⚠️</span> |
|||
<span class="toast-message"> |
|||
${I18N_KEYS['staleness.banner.message'].replace('{hours}', hoursSinceUpdate.toString())} |
|||
</span> |
|||
<button class="toast-action" onclick="window.refreshData()"> |
|||
${I18N_KEYS['staleness.banner.action_refresh']} |
|||
</button> |
|||
</div> |
|||
`;
|
|||
|
|||
// Add styles
|
|||
toast.style.cssText = ` |
|||
position: fixed; |
|||
bottom: 20px; |
|||
left: 50%; |
|||
transform: translateX(-50%); |
|||
background: #333; |
|||
color: white; |
|||
padding: 12px 16px; |
|||
border-radius: 8px; |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.3); |
|||
z-index: 1000; |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
`;
|
|||
|
|||
this.container.appendChild(toast); |
|||
|
|||
// Auto-dismiss
|
|||
setTimeout(() => { |
|||
if (toast.parentElement) { |
|||
toast.remove(); |
|||
} |
|||
}, STALE_DATA_CONFIG.bannerAutoDismissMs); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Stale Data Manager |
|||
*/ |
|||
class StaleDataManager { |
|||
private platform: 'android' | 'ios' | 'web'; |
|||
private ux: AndroidStaleDataUX | iOSStaleDataUX | WebStaleDataUX; |
|||
private lastSuccessfulPoll: number = 0; |
|||
|
|||
constructor(platform: 'android' | 'ios' | 'web', context?: any) { |
|||
this.platform = platform; |
|||
|
|||
switch (platform) { |
|||
case 'android': |
|||
this.ux = new AndroidStaleDataUX(context); |
|||
break; |
|||
case 'ios': |
|||
this.ux = new iOSStaleDataUX(context); |
|||
break; |
|||
case 'web': |
|||
this.ux = new WebStaleDataUX(context); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
updateLastSuccessfulPoll(): void { |
|||
this.lastSuccessfulPoll = Date.now(); |
|||
} |
|||
|
|||
checkAndShowStaleDataBanner(): void { |
|||
const hoursSinceUpdate = (Date.now() - this.lastSuccessfulPoll) / (1000 * 60 * 60); |
|||
|
|||
if (hoursSinceUpdate >= STALE_DATA_CONFIG.staleThresholdHours) { |
|||
this.ux.showStalenessBanner(Math.floor(hoursSinceUpdate)); |
|||
} |
|||
} |
|||
|
|||
isDataStale(): boolean { |
|||
const hoursSinceUpdate = (Date.now() - this.lastSuccessfulPoll) / (1000 * 60 * 60); |
|||
return hoursSinceUpdate >= STALE_DATA_CONFIG.staleThresholdHours; |
|||
} |
|||
|
|||
isDataCritical(): boolean { |
|||
const hoursSinceUpdate = (Date.now() - this.lastSuccessfulPoll) / (1000 * 60 * 60); |
|||
return hoursSinceUpdate >= STALE_DATA_CONFIG.criticalThresholdHours; |
|||
} |
|||
|
|||
getHoursSinceUpdate(): number { |
|||
return (Date.now() - this.lastSuccessfulPoll) / (1000 * 60 * 60); |
|||
} |
|||
} |
|||
|
|||
// Global functions for web
|
|||
if (typeof window !== 'undefined') { |
|||
(window as any).refreshData = () => { |
|||
console.log('Refreshing data from web banner'); |
|||
}; |
|||
|
|||
(window as any).openSettings = () => { |
|||
console.log('Opening settings from web banner'); |
|||
}; |
|||
} |
|||
|
|||
export { |
|||
StaleDataManager, |
|||
AndroidStaleDataUX, |
|||
iOSStaleDataUX, |
|||
WebStaleDataUX, |
|||
STALE_DATA_CONFIG, |
|||
I18N_KEYS |
|||
}; |
Loading…
Reference in new issue