diff --git a/INTEGRATION_GUIDE.md b/INTEGRATION_GUIDE.md index 8cc9619..c322b9f 100644 --- a/INTEGRATION_GUIDE.md +++ b/INTEGRATION_GUIDE.md @@ -1,13 +1,23 @@ # TimeSafari Daily Notification Plugin Integration Guide **Author**: Matthew Raymer -**Version**: 2.0.0 +**Version**: 2.1.0 **Created**: 2025-01-27 12:00:00 UTC -**Last Updated**: 2025-01-27 12:00:00 UTC +**Last Updated**: 2025-10-07 04:32:12 UTC ## Overview -This document provides comprehensive step-by-step instructions for integrating the TimeSafari Daily Notification Plugin into the TimeSafari application. TimeSafari is designed to foster community building through gifts, gratitude, and collaborative projects, making it easy for users to recognize contributions, build trust networks, and organize collective action. +This document provides comprehensive step-by-step instructions for integrating the TimeSafari Daily Notification Plugin into the TimeSafari application. The plugin now features a **generic polling interface** where the host app defines the inputs and response format, and the plugin provides a robust polling routine that can be used across iOS, Android, and Web platforms. + +### New Generic Polling Architecture + +The plugin provides a **structured request/response polling system** where: + +1. **Host App Defines**: Request schema, response schema, transformation logic, notification logic +2. **Plugin Provides**: Generic polling routine with retry logic, authentication, scheduling, storage pressure management +3. **Benefits**: Platform-agnostic, flexible, testable, maintainable + +### TimeSafari Community Features The Daily Notification Plugin supports TimeSafari's community-building goals by providing reliable daily notifications for: @@ -89,16 +99,146 @@ daily-notification-plugin/ └── README.md ``` +## Generic Polling Integration + +### Quick Start with Generic Polling + +The new generic polling interface allows TimeSafari to define exactly what data it needs and how to process it: + +```typescript +import { + GenericPollingRequest, + PollingScheduleConfig, + StarredProjectsRequest, + StarredProjectsResponse +} from '@timesafari/polling-contracts'; + +// 1. Define your polling request +const starredProjectsRequest: GenericPollingRequest = { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0' + }, + body: { + planIds: [], // Will be populated from user settings + afterId: undefined, // Will be populated from watermark + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && + Array.isArray(data.data) && + typeof data.hitLimit === 'boolean' && + data.pagination && + typeof data.pagination.hasMore === 'boolean'; + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message || 'Validation failed', + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 +}; + +// 2. Schedule the polling +const scheduleConfig: PollingScheduleConfig = { + request: starredProjectsRequest, + 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: new TimeSafariStorageAdapter() + } +}; + +// 3. Execute the polling +const scheduleId = await DailyNotification.schedulePoll(scheduleConfig); +``` + +### Host App Integration Pattern + +```typescript +// TimeSafari app integration +class TimeSafariPollingService { + private pollingManager: GenericPollingManager; + + constructor() { + this.pollingManager = new GenericPollingManager(jwtManager); + } + + async setupStarredProjectsPolling(): Promise { + // Get user's starred projects + const starredProjects = await this.getUserStarredProjects(); + + // Update request body with user data + starredProjectsRequest.body.planIds = starredProjects; + + // Get current watermark + const watermark = await this.getCurrentWatermark(); + starredProjectsRequest.body.afterId = watermark; + + // Schedule the poll + const scheduleId = await this.pollingManager.schedulePoll(scheduleConfig); + + return scheduleId; + } + + async handlePollingResult(result: PollingResult): Promise { + if (result.success && result.data) { + const changes = result.data.data; + + if (changes.length > 0) { + // Generate notifications + await this.generateNotifications(changes); + + // Update watermark with CAS + const latestJwtId = changes[changes.length - 1].planSummary.jwtId; + await this.updateWatermark(latestJwtId); + + // Acknowledge changes with server + await this.acknowledgeChanges(changes.map(c => c.planSummary.jwtId)); + } + } else if (result.error) { + console.error('Polling failed:', result.error); + // Handle error (retry, notify user, etc.) + } + } +} +``` + ## Integration Steps -### 1. Install Plugin from Git Repository +### 1. Install Plugin and Contracts Package -Add the plugin to your `package.json` dependencies: +Add the plugin and contracts package to your `package.json` dependencies: ```json { "dependencies": { - "@timesafari/daily-notification-plugin": "git+https://github.com/timesafari/daily-notification-plugin.git#main" + "@timesafari/daily-notification-plugin": "git+https://github.com/timesafari/daily-notification-plugin.git#main", + "@timesafari/polling-contracts": "file:./packages/polling-contracts" } } ``` @@ -106,6 +246,7 @@ Add the plugin to your `package.json` dependencies: Or install directly via npm: ```bash npm install git+https://github.com/timesafari/daily-notification-plugin.git#main +npm install ./packages/polling-contracts ``` ### 2. Configure Capacitor @@ -161,7 +302,7 @@ const config: CapacitorConfig = { }, electronIsEncryption: false }, - // Add Daily Notification Plugin configuration for TimeSafari community features + // Add Daily Notification Plugin configuration with generic polling support DailyNotification: { // Plugin-specific configuration defaultChannel: 'timesafari_community', @@ -169,63 +310,106 @@ const config: CapacitorConfig = { enableVibration: true, enableLights: true, priority: 'high', - // Dual scheduling configuration for community updates + + // Generic Polling Support + genericPolling: { + enabled: true, + schedules: [ + // Starred Projects Polling + { + request: { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0' + }, + body: { + planIds: [], // Populated from user settings + afterId: undefined, // Populated from watermark + limit: 100 + }, + responseSchema: { + validate: (data: any) => data && Array.isArray(data.data), + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message, + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }, + 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: 'timesafari' // Use TimeSafari's storage + } + } + ], + maxConcurrentPolls: 3, + globalRetryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + } + }, + + // Legacy dual scheduling configuration (for backward compatibility) contentFetch: { enabled: true, schedule: '0 8 * * *', // 8 AM daily - fetch community updates - url: 'https://endorser.ch/api/v2/report/notifications/bundle', // Single route for all notification types + url: 'https://endorser.ch/api/v2/report/notifications/bundle', headers: { 'Authorization': 'Bearer your-jwt-token', 'Content-Type': 'application/json', 'X-Privacy-Level': 'user-controlled' }, - ttlSeconds: 3600, // 1 hour TTL for community data - timeout: 30000, // 30 second timeout + ttlSeconds: 3600, + timeout: 30000, retryAttempts: 3, retryDelay: 5000 }, userNotification: { enabled: true, - schedule: '0 9 * * *', // 9 AM daily - notify users of community updates + schedule: '0 9 * * *', title: 'TimeSafari Community Update', body: 'New offers, projects, people, and items await your attention!', sound: true, vibration: true, priority: 'high' }, - // Callback configuration for community features - callbacks: { - offers: { - enabled: true, - localHandler: 'handleOffersNotification' - }, - projects: { - enabled: true, - localHandler: 'handleProjectsNotification' - }, - people: { - enabled: true, - localHandler: 'handlePeopleNotification' - }, - items: { - enabled: true, - localHandler: 'handleItemsNotification' - }, - communityAnalytics: { - enabled: true, - endpoint: 'https://analytics.timesafari.com/community-events', - headers: { - 'Authorization': 'Bearer your-analytics-token', - 'Content-Type': 'application/json' - } - } - }, + // Observability configuration observability: { enableLogging: true, - logLevel: 'debug', + logLevel: 'info', enableMetrics: true, - enableHealthChecks: true + enableHealthChecks: true, + telemetryConfig: { + lowCardinalityMetrics: true, + piiRedaction: true, + retentionDays: 30 + } } } }, @@ -435,6 +619,15 @@ import { UserNotificationConfig, CallbackEvent } from '@timesafari/daily-notification-plugin'; +import { + GenericPollingRequest, + PollingScheduleConfig, + PollingResult, + StarredProjectsRequest, + StarredProjectsResponse, + calculateBackoffDelay, + createDefaultOutboxPressureManager +} from '@timesafari/polling-contracts'; import { logger } from '@/utils/logger'; /** @@ -949,6 +1142,172 @@ export class DailyNotificationService { public getVersion(): string { return '2.0.0'; } + + /** + * Setup generic polling for starred projects + * @param starredProjectIds Array of starred project IDs + * @param currentWatermark Current watermark JWT ID + */ + public async setupStarredProjectsPolling( + starredProjectIds: string[], + currentWatermark?: string + ): Promise { + if (!this.isInitialized) { + throw new Error('DailyNotificationService not initialized'); + } + + try { + // Create the polling request + const starredProjectsRequest: GenericPollingRequest = { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0', + 'Authorization': `Bearer ${await this.getJwtToken()}` + }, + body: { + planIds: starredProjectIds, + afterId: currentWatermark, + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && + Array.isArray(data.data) && + typeof data.hitLimit === 'boolean' && + data.pagination && + typeof data.pagination.hasMore === 'boolean'; + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message || 'Validation failed', + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }; + + // Create the schedule configuration + const scheduleConfig: PollingScheduleConfig = { + request: starredProjectsRequest, + 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: 'timesafari' + } + }; + + // Schedule the polling + const scheduleId = await DailyNotification.schedulePoll(scheduleConfig); + + logger.debug('[DailyNotificationService] Starred projects polling scheduled:', scheduleId); + return scheduleId; + } catch (error) { + logger.error('[DailyNotificationService] Failed to setup starred projects polling:', error); + throw error; + } + } + + /** + * Handle polling results + * @param result Polling result + */ + public async handlePollingResult(result: PollingResult): Promise { + if (!this.isInitialized) { + throw new Error('DailyNotificationService not initialized'); + } + + try { + if (result.success && result.data) { + const changes = result.data.data; + + if (changes.length > 0) { + // Generate notifications + await this.generateNotifications(changes); + + // Update watermark with CAS + const latestJwtId = changes[changes.length - 1].planSummary.jwtId; + await this.updateWatermark(latestJwtId); + + // Acknowledge changes with server + await this.acknowledgeChanges(changes.map(c => c.planSummary.jwtId)); + + logger.debug('[DailyNotificationService] Processed polling result:', { + changeCount: changes.length, + latestJwtId + }); + } + } else if (result.error) { + logger.error('[DailyNotificationService] Polling failed:', result.error); + // Handle error (retry, notify user, etc.) + await this.handlePollingError(result.error); + } + } catch (error) { + logger.error('[DailyNotificationService] Failed to handle polling result:', error); + throw error; + } + } + + /** + * Get JWT token for authentication + */ + private async getJwtToken(): Promise { + // Implementation would get JWT token from TimeSafari's auth system + return 'your-jwt-token'; + } + + /** + * Generate notifications from polling results + */ + private async generateNotifications(changes: any[]): Promise { + // Implementation would generate notifications based on changes + logger.debug('[DailyNotificationService] Generating notifications for changes:', changes.length); + } + + /** + * Update watermark with compare-and-swap + */ + private async updateWatermark(jwtId: string): Promise { + // Implementation would update watermark using CAS + logger.debug('[DailyNotificationService] Updating watermark:', jwtId); + } + + /** + * Acknowledge changes with server + */ + private async acknowledgeChanges(jwtIds: string[]): Promise { + // Implementation would acknowledge changes with server + logger.debug('[DailyNotificationService] Acknowledging changes:', jwtIds.length); + } + + /** + * Handle polling errors + */ + private async handlePollingError(error: any): Promise { + // Implementation would handle polling errors + logger.error('[DailyNotificationService] Handling polling error:', error); + } } ``` @@ -1029,6 +1388,25 @@ export const PlatformServiceMixin = { return await notificationService.requestBatteryOptimizationExemption(); }, + /** + * Setup generic polling for starred projects + * @param starredProjectIds Array of starred project IDs + * @param currentWatermark Current watermark JWT ID + */ + async $setupStarredProjectsPolling(starredProjectIds: string[], currentWatermark?: string): Promise { + const notificationService = DailyNotificationService.getInstance(); + return await notificationService.setupStarredProjectsPolling(starredProjectIds, currentWatermark); + }, + + /** + * Handle polling results + * @param result Polling result + */ + async $handlePollingResult(result: any): Promise { + const notificationService = DailyNotificationService.getInstance(); + return await notificationService.handlePollingResult(result); + }, + // ... rest of existing methods }; ``` @@ -1056,6 +1434,8 @@ declare module "@vue/runtime-core" { $cancelAllNotifications(): Promise; $getBatteryStatus(): Promise; $requestBatteryOptimizationExemption(): Promise; + $setupStarredProjectsPolling(starredProjectIds: string[], currentWatermark?: string): Promise; + $handlePollingResult(result: any): Promise; } } ``` @@ -1117,7 +1497,70 @@ export class CapacitorPlatformService implements PlatformService { ### 7. Usage Examples -#### 7.1 Community Update Notification +#### 7.1 Generic Polling for Starred Projects + +```typescript +// In a Vue component +export default { + data() { + return { + starredProjects: [], + currentWatermark: null, + pollingScheduleId: null + }; + }, + + async mounted() { + await this.initializePolling(); + }, + + methods: { + async initializePolling() { + try { + // Get user's starred projects + this.starredProjects = await this.getUserStarredProjects(); + + // Get current watermark + this.currentWatermark = await this.getCurrentWatermark(); + + // Setup polling + this.pollingScheduleId = await this.$setupStarredProjectsPolling( + this.starredProjects, + this.currentWatermark + ); + + this.$notify('Starred projects polling initialized successfully'); + } catch (error) { + this.$notify('Failed to initialize polling: ' + error.message); + } + }, + + async getUserStarredProjects() { + // Implementation would get starred projects from TimeSafari's database + return ['project-1', 'project-2', 'project-3']; + }, + + async getCurrentWatermark() { + // Implementation would get current watermark from storage + return '1704067200_abc123_12345678'; + }, + + async handlePollingResult(result) { + try { + await this.$handlePollingResult(result); + + if (result.success && result.data && result.data.data.length > 0) { + this.$notify(`Received ${result.data.data.length} project updates`); + } + } catch (error) { + this.$notify('Failed to handle polling result: ' + error.message); + } + } + } +}; +``` + +#### 7.2 Community Update Notification ```typescript // In a Vue component @@ -1606,7 +2049,7 @@ For questions or issues, refer to the plugin's documentation or contact the Time --- -**Version**: 2.0.0 -**Last Updated**: 2025-01-27 12:00:00 UTC +**Version**: 2.1.0 +**Last Updated**: 2025-10-07 04:32:12 UTC **Status**: Production Ready **Author**: Matthew Raymer diff --git a/examples/hello-poll.ts b/examples/hello-poll.ts new file mode 100644 index 0000000..e0fc6c1 --- /dev/null +++ b/examples/hello-poll.ts @@ -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 { + // 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(); + + async get(key: string): Promise { + return this.storage.get(key); + } + + async set(key: string, value: any): Promise { + this.storage.set(key, value); + } + + async delete(key: string): Promise { + this.storage.delete(key); + } + + async exists(key: string): Promise { + return this.storage.has(key); + } +} + +// Mock authentication manager +class MockAuthManager { + private token = 'mock_jwt_token'; + + async getCurrentToken(): Promise { + return this.token; + } + + async refreshToken(): Promise { + this.token = `mock_jwt_token_${Date.now()}`; + return this.token; + } + + async validateToken(token: string): Promise { + 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( + request: GenericPollingRequest + ): Promise> { + 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( + config: PollingScheduleConfig + ): Promise { + 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 { + 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 = { + 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 = { + 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 }; diff --git a/examples/stale-data-ux.ts b/examples/stale-data-ux.ts new file mode 100644 index 0000000..4a4b8be --- /dev/null +++ b/examples/stale-data-ux.ts @@ -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 = ` + + `; + + // 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 = ` +
+ ⚠️ + + ${I18N_KEYS['staleness.banner.message'].replace('{hours}', hoursSinceUpdate.toString())} + + +
+ `; + + // 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 +};