docs(refactor): add integration point refactor analysis and implementation plan

Add comprehensive documentation and implementation artifacts for refactoring
the plugin to use app-provided content fetchers instead of hardcoded TimeSafari
integration.

Changes:
- Add integration-point-refactor-analysis.md with complete ADR, interfaces,
  migration plan, and 7-PR breakdown
- Add INTEGRATION_REFACTOR_QUICK_START.md for quick reference on new machines
- Add src/types/content-fetcher.ts with TypeScript SPI interfaces
- Add examples/native-fetcher-android.kt with Kotlin implementation skeleton
- Add examples/js-fetcher-typescript.ts with TypeScript implementation skeleton
- Add tests/fixtures/test-contract.json for golden contract testing

Architecture Decisions:
- Dual-path SPI: Native Fetcher (background) + JS Fetcher (foreground only)
- Background reliability: Native SPI only, no JS bridging in workers
- Reversibility: Legacy code behind feature flag for one minor release
- Test contract: Single JSON fixture for both fetcher paths

This provides complete specification for implementing the refactor in 7 PRs,
starting with SPI shell and progressing through background workers, deduplication,
failure policies, and finally legacy code removal.

All documentation is self-contained and ready for implementation on any machine.
This commit is contained in:
Matthew Raymer
2025-10-29 13:04:49 +00:00
parent ed5dcfbbd1
commit e83b1518d7
6 changed files with 1635 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
/**
* JavaScript Content Fetcher Implementation (TypeScript)
*
* Example implementation of JsNotificationContentFetcher for foreground/manual refresh.
*
* NOTE: This is used ONLY for foreground operations. Background workers use
* the Native Fetcher SPI (Kotlin/Swift) for reliability.
*
* @author Matthew Raymer
* @version 1.0.0
*/
import { DailyNotification } from '@timesafari/daily-notification-plugin';
import type {
JsNotificationContentFetcher,
FetchContext,
NotificationContent
} from '@timesafari/daily-notification-plugin';
import { TimeSafariAPI } from './timesafari-api'; // Host app's API client
/**
* TimeSafari JavaScript content fetcher
*
* Implements JsNotificationContentFetcher for foreground/manual refresh.
* Background workers use the native Kotlin/Swift implementation.
*/
class TimeSafariJsFetcher implements JsNotificationContentFetcher {
constructor(
private api: TimeSafariAPI,
private activeDid: string,
private starredPlanIds: string[]
) {}
async fetchContent(context: FetchContext): Promise<NotificationContent[]> {
try {
// 1. Generate JWT for authentication
const jwt = await this.api.generateJWT(this.activeDid);
// 2. Fetch TimeSafari data in parallel
const [offersToPerson, offersToPlans, projectUpdates] = await Promise.all([
this.api.fetchOffersToPerson(jwt),
this.api.fetchOffersToPlans(jwt),
this.starredPlanIds.length > 0
? this.api.fetchProjectsLastUpdated(this.starredPlanIds, jwt)
: Promise.resolve(null)
]);
// 3. Convert to NotificationContent array
const contents: NotificationContent[] = [];
// Convert offers to person
if (offersToPerson?.data) {
for (const offer of offersToPerson.data) {
contents.push({
id: `offer_person_${offer.id}`,
title: `New Offer: ${offer.title}`,
body: offer.description || '',
scheduledTime: context.scheduledTime || Date.now() + 3600000,
fetchTime: context.fetchTime,
mediaUrl: offer.imageUrl,
ttlSeconds: 86400, // 24 hours
dedupeKey: `offer_person_${offer.id}_${offer.updatedAt}`,
priority: 'default',
metadata: {
offerId: offer.id,
issuerDid: offer.issuerDid,
source: 'offers_to_person'
}
});
}
}
// Convert offers to plans
if (offersToPlans?.data) {
for (const offer of offersToPlans.data) {
contents.push({
id: `offer_plan_${offer.id}`,
title: `Offer for Your Project: ${offer.projectName}`,
body: offer.description || '',
scheduledTime: context.scheduledTime || Date.now() + 3600000,
fetchTime: context.fetchTime,
mediaUrl: offer.imageUrl,
ttlSeconds: 86400,
dedupeKey: `offer_plan_${offer.id}_${offer.updatedAt}`,
priority: 'default',
metadata: {
offerId: offer.id,
planHandleId: offer.planHandleId,
source: 'offers_to_plans'
}
});
}
}
// Convert project updates
if (projectUpdates?.data) {
for (const update of projectUpdates.data) {
contents.push({
id: `project_${update.planSummary.jwtId}`,
title: `${update.planSummary.name} Updated`,
body: `New updates for ${update.planSummary.name}`,
scheduledTime: context.scheduledTime || Date.now() + 3600000,
fetchTime: context.fetchTime,
mediaUrl: update.planSummary.image,
ttlSeconds: 86400,
dedupeKey: `project_${update.planSummary.handleId}_${update.planSummary.jwtId}`,
priority: 'default',
metadata: {
planHandleId: update.planSummary.handleId,
jwtId: update.planSummary.jwtId,
source: 'project_updates'
}
});
}
}
return contents;
} catch (error) {
console.error('TimeSafari fetch failed:', error);
// Return empty array - plugin will handle retry based on policy
return [];
}
}
}
/**
* Register fetcher and configure policy
*/
export async function setupDailyNotification(
api: TimeSafariAPI,
activeDid: string,
starredPlanIds: string[]
): Promise<void> {
// Create JS fetcher for foreground operations
const jsFetcher = new TimeSafariJsFetcher(api, activeDid, starredPlanIds);
DailyNotification.setJsContentFetcher(jsFetcher);
// Configure scheduling policy
await DailyNotification.setPolicy({
prefetchWindowMs: 5 * 60 * 1000, // 5 minutes before scheduled time
retryBackoff: {
minMs: 2000,
maxMs: 600000, // 10 minutes max
factor: 2,
jitterPct: 20
},
maxBatchSize: 50,
dedupeHorizonMs: 24 * 60 * 60 * 1000, // 24 hours
cacheTtlSeconds: 6 * 60 * 60, // 6 hours default
exactAlarmsAllowed: true // If app has permission
});
// Enable native fetcher (required for background workers)
await DailyNotification.enableNativeFetcher(true);
}

View File

@@ -0,0 +1,153 @@
/**
* TimeSafari Native Fetcher Implementation (Android/Kotlin)
*
* Example implementation of NativeNotificationContentFetcher for Android.
* This runs in background workers and does NOT require JavaScript bridge.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.notification
import com.timesafari.dailynotification.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* TimeSafari native content fetcher
*
* Implements the NativeNotificationContentFetcher SPI to provide
* TimeSafari-specific notification content fetching for background workers.
*/
class TimeSafariNativeFetcher(
private val api: TimeSafariApi,
private val tokenProvider: TokenProvider,
private val starredPlanIds: List<String>
) : NativeNotificationContentFetcher {
override suspend fun fetchContent(context: FetchContext): List<NotificationContent> {
return withContext(Dispatchers.IO) {
try {
// 1. Get fresh authentication token
val jwt = tokenProvider.freshToken()
// 2. Fetch TimeSafari data in parallel
val offersToPerson = async { api.fetchOffersToPerson(jwt) }
val offersToPlans = async { api.fetchOffersToPlans(jwt) }
val projectUpdates = async {
if (starredPlanIds.isNotEmpty()) {
api.fetchProjectsLastUpdated(starredPlanIds, jwt)
} else {
null
}
}
// Wait for all requests
val offersPersonResult = offersToPerson.await()
val offersPlansResult = offersToPlans.await()
val updatesResult = projectUpdates.await()
// 3. Convert to NotificationContent list
buildList {
// Add offers to person
offersPersonResult?.data?.forEach { offer ->
add(
NotificationContent(
id = "offer_person_${offer.id}",
title = "New Offer: ${offer.title}",
body = offer.description ?: "",
scheduledTime = context.scheduledTime
?: (System.currentTimeMillis() + 3_600_000),
fetchTime = context.fetchTime,
mediaUrl = offer.imageUrl,
ttlSeconds = 86_400, // 24 hours
dedupeKey = "offer_person_${offer.id}_${offer.updatedAt}",
priority = "default",
metadata = mapOf(
"offerId" to offer.id,
"issuerDid" to offer.issuerDid,
"source" to "offers_to_person"
)
)
)
}
// Add offers to plans
offersPlansResult?.data?.forEach { offer ->
add(
NotificationContent(
id = "offer_plan_${offer.id}",
title = "Offer for Your Project: ${offer.projectName}",
body = offer.description ?: "",
scheduledTime = context.scheduledTime
?: (System.currentTimeMillis() + 3_600_000),
fetchTime = context.fetchTime,
mediaUrl = offer.imageUrl,
ttlSeconds = 86_400,
dedupeKey = "offer_plan_${offer.id}_${offer.updatedAt}",
priority = "default",
metadata = mapOf(
"offerId" to offer.id,
"planHandleId" to offer.planHandleId,
"source" to "offers_to_plans"
)
)
)
}
// Add project updates
updatesResult?.data?.forEach { update ->
add(
NotificationContent(
id = "project_${update.planSummary.jwtId}",
title = "${update.planSummary.name} Updated",
body = "New updates for ${update.planSummary.name}",
scheduledTime = context.scheduledTime
?: (System.currentTimeMillis() + 3_600_000),
fetchTime = context.fetchTime,
mediaUrl = update.planSummary.image,
ttlSeconds = 86_400,
dedupeKey = "project_${update.planSummary.handleId}_${update.planSummary.jwtId}",
priority = "default",
metadata = mapOf(
"planHandleId" to update.planSummary.handleId,
"jwtId" to update.planSummary.jwtId,
"source" to "project_updates"
)
)
)
}
}
} catch (e: Exception) {
// Log error and return empty list
// Plugin will handle retry based on SchedulingPolicy
android.util.Log.e("TimeSafariFetcher", "Fetch failed", e)
emptyList()
}
}
}
}
/**
* Registration in Application.onCreate()
*/
class TimeSafariApplication : Application() {
override fun onCreate() {
super.onCreate()
// Register native fetcher
val api = TimeSafariApiClient(context = this)
val tokenProvider = JWTokenProvider(activeDid = getActiveDid())
val starredPlanIds = getStarredPlanIds()
val fetcher = TimeSafariNativeFetcher(
api = api,
tokenProvider = tokenProvider,
starredPlanIds = starredPlanIds
)
DailyNotification.setNativeFetcher(fetcher)
}
}