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