Browse Source

docs(integration): update integration guide and add host app examples

- 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
Matthew Raymer 4 days ago
parent
commit
8ee97e5401
  1. 531
      INTEGRATION_GUIDE.md
  2. 319
      examples/hello-poll.ts
  3. 360
      examples/stale-data-ux.ts

531
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<StarredProjectsRequest, StarredProjectsResponse> = {
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<StarredProjectsRequest, StarredProjectsResponse> = {
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<string> {
// 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<StarredProjectsResponse>): Promise<void> {
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<string> {
if (!this.isInitialized) {
throw new Error('DailyNotificationService not initialized');
}
try {
// Create the polling request
const starredProjectsRequest: GenericPollingRequest<StarredProjectsRequest, StarredProjectsResponse> = {
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<StarredProjectsRequest, StarredProjectsResponse> = {
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<StarredProjectsResponse>): Promise<void> {
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<string> {
// 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<void> {
// 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<void> {
// Implementation would update watermark using CAS
logger.debug('[DailyNotificationService] Updating watermark:', jwtId);
}
/**
* Acknowledge changes with server
*/
private async acknowledgeChanges(jwtIds: string[]): Promise<void> {
// Implementation would acknowledge changes with server
logger.debug('[DailyNotificationService] Acknowledging changes:', jwtIds.length);
}
/**
* Handle polling errors
*/
private async handlePollingError(error: any): Promise<void> {
// 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<string> {
const notificationService = DailyNotificationService.getInstance();
return await notificationService.setupStarredProjectsPolling(starredProjectIds, currentWatermark);
},
/**
* Handle polling results
* @param result Polling result
*/
async $handlePollingResult(result: any): Promise<void> {
const notificationService = DailyNotificationService.getInstance();
return await notificationService.handlePollingResult(result);
},
// ... rest of existing methods
};
```
@ -1056,6 +1434,8 @@ declare module "@vue/runtime-core" {
$cancelAllNotifications(): Promise<void>;
$getBatteryStatus(): Promise<any>;
$requestBatteryOptimizationExemption(): Promise<void>;
$setupStarredProjectsPolling(starredProjectIds: string[], currentWatermark?: string): Promise<string>;
$handlePollingResult(result: any): Promise<void>;
}
}
```
@ -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

319
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<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 };

360
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 = `
<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…
Cancel
Save