diff --git a/src/declarations/api_canister/api_canister.did b/src/declarations/api_canister/api_canister.did index b69a2602..7585992f 100644 --- a/src/declarations/api_canister/api_canister.did +++ b/src/declarations/api_canister/api_canister.did @@ -212,6 +212,71 @@ type ApiError = Unauthorized; ZeroAddress; }; +// Activity Feed Types +type ActivityFeedQuery = + record { + page: opt nat; + limit: opt nat; + }; +type ActivityFeedChallenge = + record { + challengeId: text; + challengeQuestion: text; + challengeTopic: text; + challengeCreationTimestamp: nat64; + challengeStatus: variant { Open; Closed; Archived; }; + }; +type ActivityFeedParticipant = + record { + submittedBy: principal; + submissionId: text; + ownedBy: principal; + reward: record { + amount: nat; + rewardType: variant { MainerToken; Cycles; ICP; }; + distributed: bool; + }; + result: variant { Winner; SecondPlace; ThirdPlace; Participated; }; + }; +type ActivityFeedWinner = + record { + challengeId: text; + challengeQuestion: text; + finalizedTimestamp: nat64; + winner: ActivityFeedParticipant; + secondPlace: ActivityFeedParticipant; + thirdPlace: opt ActivityFeedParticipant; + }; +type ActivityFeedResponse = + record { + open_challenges: vec ActivityFeedChallenge; + winners: vec ActivityFeedWinner; + has_more: bool; + total_count: nat; + }; +type ActivityFeedResult = + variant { + Err: ApiError; + Ok: ActivityFeedResponse; + }; +type ActivityFeedCacheStatus = + record { + last_updated: nat64; + total_items: nat; + open_challenges_count: nat; + winners_count: nat; + }; +type ActivityFeedCacheStatusResult = + variant { + Err: ApiError; + Ok: ActivityFeedCacheStatus; + }; +type OpenChallengesResult = + variant { + Err: ApiError; + Ok: vec ActivityFeedChallenge; + }; + type ApiCanister = service { amiController: () -> (AuthRecordResult) query; @@ -220,6 +285,8 @@ type ApiCanister = bulkCreateDailyMetricsAdmin: (inputs: vec DailyMetricInput) -> (NatResult); createDailyMetricAdmin: (input: DailyMetricInput) -> (DailyMetricResult); deleteDailyMetricAdmin: (date: text) -> (NatResult); + getActivityFeed: (query: ActivityFeedQuery) -> (ActivityFeedResult) query; + getActivityFeedCacheStatus: () -> (ActivityFeedCacheStatusResult) query; getAdminRoles: () -> (AdminRoleAssignmentsResult) query; getDailyMetricByDate: (date: text) -> (DailyMetricResult) query; getDailyMetrics: (dailyMetricsQuery: opt DailyMetricsQuery) -> @@ -228,6 +295,7 @@ type ApiCanister = getLatestDailyMetric: () -> (DailyMetricResult) query; getMasterCanisterId: () -> (AuthRecordResult) query; getNumDailyMetrics: () -> (NatResult) query; + getOpenChallengesFromCache: () -> (OpenChallengesResult) query; getTokenRewardsData: () -> (TokenRewardsDataResult) query; health: () -> (StatusCodeRecordResult) query; resetDailyMetricsAdmin: () -> (NatResult); diff --git a/src/declarations/api_canister/api_canister.did.d.ts b/src/declarations/api_canister/api_canister.did.d.ts index b815891f..1496221e 100644 --- a/src/declarations/api_canister/api_canister.did.d.ts +++ b/src/declarations/api_canister/api_canister.did.d.ts @@ -27,6 +27,9 @@ export interface ApiCanister { >, 'createDailyMetricAdmin' : ActorMethod<[DailyMetricInput], DailyMetricResult>, 'deleteDailyMetricAdmin' : ActorMethod<[string], NatResult>, + 'getActivityFeed' : ActorMethod<[ActivityFeedQuery], ActivityFeedResult>, + 'getActivityFeedCacheStatus' : ActorMethod<[], CacheStatusResult>, + 'getActivityFeedSyncIntervalAdmin' : ActorMethod<[], NatResult>, 'getAdminRoles' : ActorMethod<[], AdminRoleAssignmentsResult>, 'getDailyMetricByDate' : ActorMethod<[string], DailyMetricResult>, 'getDailyMetrics' : ActorMethod< @@ -37,11 +40,15 @@ export interface ApiCanister { 'getLatestDailyMetric' : ActorMethod<[], DailyMetricResult>, 'getMasterCanisterId' : ActorMethod<[], AuthRecordResult>, 'getNumDailyMetrics' : ActorMethod<[], NatResult>, + 'getOpenChallengesFromCache' : ActorMethod<[], ChallengesResult>, 'getTokenRewardsData' : ActorMethod<[], TokenRewardsDataResult>, 'health' : ActorMethod<[], StatusCodeRecordResult>, 'resetDailyMetricsAdmin' : ActorMethod<[], NatResult>, 'revokeAdminRole' : ActorMethod<[string], TextResult>, + 'setActivityFeedSyncIntervalAdmin' : ActorMethod<[bigint], StatusCodeRecordResult>, 'setMasterCanisterId' : ActorMethod<[string], AuthRecordResult>, + 'startActivityFeedTimerAdmin' : ActorMethod<[], AuthRecordResult>, + 'stopActivityFeedTimerAdmin' : ActorMethod<[], AuthRecordResult>, 'updateDailyMetricAdmin' : ActorMethod< [UpdateDailyMetricAdminInput], DailyMetricResult @@ -55,6 +62,80 @@ export type ApiError = { 'FailedOperation' : null } | { 'StatusCode' : StatusCode } | { 'Other' : string } | { 'InsuffientCycles' : bigint }; + +// Activity Feed Types +export interface ActivityFeedQuery { + 'winnersLimit' : [] | [bigint], + 'winnersOffset' : [] | [bigint], + 'challengesLimit' : [] | [bigint], + 'challengesOffset' : [] | [bigint], + 'sinceTimestamp' : [] | [bigint], +} +export interface ChallengeParticipantEntry { + 'submissionId' : string, + 'submittedBy' : Principal, + 'ownedBy' : Principal, + 'result' : ChallengeParticipationResult, + 'reward' : ChallengeWinnerReward, +} +export type ChallengeParticipationResult = { 'Winner' : null } | + { 'SecondPlace' : null } | + { 'ThirdPlace' : null } | + { 'Participated' : null } | + { 'Other' : string }; +export interface ChallengeWinnerReward { + 'amount' : bigint, + 'rewardType' : RewardType, + 'rewardDetails' : string, + 'distributed' : boolean, + 'distributedTimestamp' : [] | [bigint], +} +export type RewardType = { 'MainerToken' : null } | + { 'Cycles' : null } | + { 'ICP' : null } | + { 'Coupon' : string } | + { 'Other' : string }; +export interface ChallengeWinnerDeclarationArray { + 'challengeId' : string, + 'finalizedTimestamp' : bigint, + 'winner' : ChallengeParticipantEntry, + 'secondPlace' : ChallengeParticipantEntry, + 'thirdPlace' : ChallengeParticipantEntry, + 'participants' : Array, +} +export type ChallengeStatus = { 'Open' : null } | + { 'Closed' : null } | + { 'Archived' : null } | + { 'Other' : string }; +export interface Challenge { + 'challengeId' : string, + 'challengeQuestion' : string, + 'challengeTopic' : string, + 'challengeCreationTimestamp' : bigint, + 'challengeCreatedBy' : string, + 'challengeStatus' : ChallengeStatus, + 'challengeClosedTimestamp' : [] | [bigint], +} +export interface ActivityFeedResponse { + 'winners' : Array, + 'challenges' : Array, + 'totalWinners' : bigint, + 'totalChallenges' : bigint, + 'cacheTimestamp' : bigint, +} +export type ActivityFeedResult = { 'Ok' : ActivityFeedResponse } | + { 'Err' : ApiError }; +export interface CacheStatus { + 'lastSyncTimestamp' : bigint, + 'cachedWinnersCount' : bigint, + 'cachedChallengesCount' : bigint, + 'syncIntervalSeconds' : bigint, +} +export type CacheStatusResult = { 'Ok' : CacheStatus } | + { 'Err' : ApiError }; +export type ChallengesResult = { 'Ok' : Array } | + { 'Err' : ApiError }; + export interface AssignAdminRoleInputRecord { 'principal' : string, 'note' : string, diff --git a/src/declarations/api_canister/api_canister.did.js b/src/declarations/api_canister/api_canister.did.js index 308e2714..a788f5ed 100644 --- a/src/declarations/api_canister/api_canister.did.js +++ b/src/declarations/api_canister/api_canister.did.js @@ -114,6 +114,92 @@ export const idlFactory = ({ IDL }) => { 'Ok' : DailyMetric, 'Err' : ApiError, }); + + // Activity Feed Types + const ActivityFeedQuery = IDL.Record({ + 'winnersLimit' : IDL.Opt(IDL.Nat), + 'winnersOffset' : IDL.Opt(IDL.Nat), + 'challengesLimit' : IDL.Opt(IDL.Nat), + 'challengesOffset' : IDL.Opt(IDL.Nat), + 'sinceTimestamp' : IDL.Opt(IDL.Nat64), + }); + const ChallengeParticipationResult = IDL.Variant({ + 'Winner' : IDL.Null, + 'SecondPlace' : IDL.Null, + 'ThirdPlace' : IDL.Null, + 'Participated' : IDL.Null, + 'Other' : IDL.Text, + }); + const RewardType = IDL.Variant({ + 'MainerToken' : IDL.Null, + 'Cycles' : IDL.Null, + 'ICP' : IDL.Null, + 'Coupon' : IDL.Text, + 'Other' : IDL.Text, + }); + const ChallengeWinnerReward = IDL.Record({ + 'amount' : IDL.Nat, + 'rewardType' : RewardType, + 'rewardDetails' : IDL.Text, + 'distributed' : IDL.Bool, + 'distributedTimestamp' : IDL.Opt(IDL.Nat64), + }); + const ChallengeParticipantEntry = IDL.Record({ + 'submissionId' : IDL.Text, + 'submittedBy' : IDL.Principal, + 'ownedBy' : IDL.Principal, + 'result' : ChallengeParticipationResult, + 'reward' : ChallengeWinnerReward, + }); + const ChallengeWinnerDeclarationArray = IDL.Record({ + 'challengeId' : IDL.Text, + 'finalizedTimestamp' : IDL.Nat64, + 'winner' : ChallengeParticipantEntry, + 'secondPlace' : ChallengeParticipantEntry, + 'thirdPlace' : ChallengeParticipantEntry, + 'participants' : IDL.Vec(ChallengeParticipantEntry), + }); + const ChallengeStatus = IDL.Variant({ + 'Open' : IDL.Null, + 'Closed' : IDL.Null, + 'Archived' : IDL.Null, + 'Other' : IDL.Text, + }); + const Challenge = IDL.Record({ + 'challengeId' : IDL.Text, + 'challengeQuestion' : IDL.Text, + 'challengeTopic' : IDL.Text, + 'challengeCreationTimestamp' : IDL.Nat64, + 'challengeCreatedBy' : IDL.Text, + 'challengeStatus' : ChallengeStatus, + 'challengeClosedTimestamp' : IDL.Opt(IDL.Nat64), + }); + const ActivityFeedResponse = IDL.Record({ + 'winners' : IDL.Vec(ChallengeWinnerDeclarationArray), + 'challenges' : IDL.Vec(Challenge), + 'totalWinners' : IDL.Nat, + 'totalChallenges' : IDL.Nat, + 'cacheTimestamp' : IDL.Nat64, + }); + const ActivityFeedResult = IDL.Variant({ + 'Ok' : ActivityFeedResponse, + 'Err' : ApiError, + }); + const CacheStatus = IDL.Record({ + 'lastSyncTimestamp' : IDL.Nat64, + 'cachedWinnersCount' : IDL.Nat, + 'cachedChallengesCount' : IDL.Nat, + 'syncIntervalSeconds' : IDL.Nat, + }); + const CacheStatusResult = IDL.Variant({ + 'Ok' : CacheStatus, + 'Err' : ApiError, + }); + const ChallengesResult = IDL.Variant({ + 'Ok' : IDL.Vec(Challenge), + 'Err' : ApiError, + }); + const AdminRoleAssignmentsResult = IDL.Variant({ 'Ok' : IDL.Vec(AdminRoleAssignment), 'Err' : ApiError, @@ -209,6 +295,9 @@ export const idlFactory = ({ IDL }) => { [], ), 'deleteDailyMetricAdmin' : IDL.Func([IDL.Text], [NatResult], []), + 'getActivityFeed' : IDL.Func([ActivityFeedQuery], [ActivityFeedResult], ['query']), + 'getActivityFeedCacheStatus' : IDL.Func([], [CacheStatusResult], ['query']), + 'getActivityFeedSyncIntervalAdmin' : IDL.Func([], [NatResult], ['query']), 'getAdminRoles' : IDL.Func([], [AdminRoleAssignmentsResult], ['query']), 'getDailyMetricByDate' : IDL.Func( [IDL.Text], @@ -224,11 +313,15 @@ export const idlFactory = ({ IDL }) => { 'getLatestDailyMetric' : IDL.Func([], [DailyMetricResult], ['query']), 'getMasterCanisterId' : IDL.Func([], [AuthRecordResult], ['query']), 'getNumDailyMetrics' : IDL.Func([], [NatResult], ['query']), + 'getOpenChallengesFromCache' : IDL.Func([], [ChallengesResult], ['query']), 'getTokenRewardsData' : IDL.Func([], [TokenRewardsDataResult], ['query']), 'health' : IDL.Func([], [StatusCodeRecordResult], ['query']), 'resetDailyMetricsAdmin' : IDL.Func([], [NatResult], []), 'revokeAdminRole' : IDL.Func([IDL.Text], [TextResult], []), + 'setActivityFeedSyncIntervalAdmin' : IDL.Func([IDL.Nat], [StatusCodeRecordResult], []), 'setMasterCanisterId' : IDL.Func([IDL.Text], [AuthRecordResult], []), + 'startActivityFeedTimerAdmin' : IDL.Func([], [AuthRecordResult], []), + 'stopActivityFeedTimerAdmin' : IDL.Func([], [AuthRecordResult], []), 'updateDailyMetricAdmin' : IDL.Func( [UpdateDailyMetricAdminInput], [DailyMetricResult], diff --git a/src/funnai_frontend/components/funnai/MainerFeed.svelte b/src/funnai_frontend/components/funnai/MainerFeed.svelte index ea6b38fc..d72bea3f 100644 --- a/src/funnai_frontend/components/funnai/MainerFeed.svelte +++ b/src/funnai_frontend/components/funnai/MainerFeed.svelte @@ -1,171 +1,38 @@
- - -
+ +
- - {#if (!$store.isAuthed) || (feedItems.length === 0 && !loading && !updating)} + + + {#if error} +
+ {error} + +
+ {/if} + + + {#if !showAllEvents && !loading}
🤖

- mAIner activity feed + My mAIners Activity

-

- This feed displays activity from mAIner agents including: +

+ This tab will show activity specific to your mAIners:

-
    -
  • • 🎯 Challenges in the protocol
  • - {#if $store.isAuthed} -
  • • 💭 Responses from your mAIners
  • -
  • • 📊 Scores your mAIners receive
  • - {/if} - {#if !showAllEvents} +
    • • 🏆 Your mAIners' victories and placements
    • -
    • • 🎯 Participation rewards earned
    • - {/if} - {#if !$store.isAuthed} -
    • • 💭 Responses from mAIners
    • -
    • • 📊 Scores received by mAIners
    • - {/if} +
    • • 🎯 Challenges your mAIners participated in
    • +
    • • 💰 Rewards earned by your mAIners
    - {#if !$store.isAuthed} -

    - Connect your wallet to create your own mAIners and see personalized activity. -

    - {:else} -

    - {showAllEvents ? 'No recent activity in the protocol.' : 'Loading activity from your mAIners.'} -

    - {/if} +

    + Coming soon - this feature is under development. +

    +
+
+
+ {/if} + + + {#if showAllEvents && feedItems.length === 0 && !loading && !updating} +
+
+
📡
+
+

+ Protocol Activity Feed +

+

+ No recent activity in the protocol. +

+

+ Open challenges and winner announcements will appear here. +

{/if} - {#if feedItems.length > 0 || (loading && $store.isAuthed)} + + {#if showAllEvents && (feedItems.length > 0 || loading)}

    - {showAllEvents ? 'No recent activity in the protocol.' : 'Loading activity from your mAIners.'} + Loading activity...

    {:else} - {#each feedItems.filter(item => !showAllEvents || (item.type !== 'winner' && item.type !== 'participation')) as item (item.id)} + {#each feedItems as item (item.id)}
  • -
    +

    - {#if item.type === 'winner'} - {getWinnerIcon(item.content.placement || '')} + {#if isWinnerType(item.type)} + {getWinnerIcon(item.type)} {/if} - {item.mainerName} - {#if item.type === 'winner'} - {getWinnerIcon(item.content.placement || '')} + {item.type === "challenge" ? "Protocol" : getMainerName(item.mainerAddress)} + {#if isWinnerType(item.type)} + {getWinnerIcon(item.type)} {/if}
    @@ -652,30 +277,50 @@
    {formatTimestamp(item.timestamp).date}
    {formatTimestamp(item.timestamp).time}
    - +

    - {#if item.type === 'challenge'} -

    New challenge: {item.content.challenge}

    - {:else if item.type === 'response'} -

    Submitted response: {item.content.response}

    - {:else if item.type === 'score'} -

    Received score: {item.content.score}/5

    - {:else if item.type === 'winner'} -
    -

    - 🎉 CONGRATULATIONS! 🎉 -

    -

    - Achieved {item.content.placement} -

    + + {#if item.type === "challenge"} +

    + New challenge: {item.challengeQuestion} +

    + {:else if isWinnerType(item.type)} +
    + {#if !showAllEvents} + +

    + 🎉 CONGRATULATIONS! 🎉 +

    + {/if}

    - and earned {formatFunnaiAmount(item.content.reward || '0')} FUNNAI + {#if showAllEvents} + + Won {getPlacementText(item.type)} {#if item.reward}and earned {formatFunnaiAmount(item.reward.toString())} FUNNAI{/if} + {:else} + + Achieved {getPlacementText(item.type)} + {/if}

    + {#if !showAllEvents && item.reward} +

    + and earned {formatFunnaiAmount(item.reward.toString())} FUNNAI +

    + {/if}
    - {:else if item.type === 'participation'} + {:else if item.type === "participation"}

    - 🎯 Earned participation reward: {formatFunnaiAmount(item.content.reward || '0')} FUNNAI + 🎯 Earned participation reward: {formatFunnaiAmount(item.reward?.toString() || '0')} FUNNAI

    {/if}
    @@ -713,15 +358,6 @@ } } - @keyframes shimmer { - 0% { - background-position: -200% 0; - } - 100% { - background-position: 200% 0; - } - } - @keyframes bounce10s { 0%, 100% { transform: translateY(-25%); @@ -745,13 +381,6 @@ animation: pulseWinner 2s 5; } - /* Shimmer effect for winner text */ - .winner-shimmer { - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); - background-size: 200% 100%; - animation: shimmer 2s 6; - } - /* Dark mode adjustments */ :global(.dark) .animate-spin { border-color: rgba(96, 165, 250, 0.8); @@ -762,4 +391,4 @@ :global(.dark) .animate-pulse-winner { box-shadow: 0 0 22px rgba(251, 191, 36, 0.3); } - \ No newline at end of file + diff --git a/src/funnai_frontend/helpers/ActivityFeedService.ts b/src/funnai_frontend/helpers/ActivityFeedService.ts new file mode 100644 index 00000000..61d5e405 --- /dev/null +++ b/src/funnai_frontend/helpers/ActivityFeedService.ts @@ -0,0 +1,388 @@ +import { store } from "../stores/store"; +import { get } from "svelte/store"; + +/** + * Activity Feed Service + * + * Fetches paginated activity feed data from the API canister. + * The API canister caches data from GameState and provides optimized queries. + */ + +// ============================================================================ +// Types matching the backend (from PoAIW/src/common/Types.mo) +// ============================================================================ + +export interface ActivityFeedQuery { + winnersLimit?: number; + winnersOffset?: number; + challengesLimit?: number; + challengesOffset?: number; + sinceTimestamp?: bigint; +} + +export interface ChallengeParticipantEntry { + submissionId: string; + submittedBy: string; // Principal as string + ownedBy: string; // Principal as string + result: { Winner: null } | { SecondPlace: null } | { ThirdPlace: null } | { Participated: null } | { Other: string }; + reward: { + amount: bigint; + rewardType: { MainerToken: null } | { Cycles: null } | { ICP: null } | { Coupon: string } | { Other: string }; + rewardDetails: string; + distributed: boolean; + distributedTimestamp?: bigint; + }; +} + +export interface ChallengeWinnerDeclaration { + challengeId: string; + finalizedTimestamp: bigint; + winner: ChallengeParticipantEntry; + secondPlace: ChallengeParticipantEntry; + thirdPlace: ChallengeParticipantEntry; + participants: ChallengeParticipantEntry[]; +} + +export interface Challenge { + challengeId: string; + challengeQuestion: string; + challengeTopic: string; + challengeCreationTimestamp: bigint; + challengeCreatedBy: string; + challengeStatus: { Open: null } | { Closed: null } | { Archived: null } | { Other: string }; + challengeClosedTimestamp?: bigint; +} + +export interface ActivityFeedResponse { + winners: ChallengeWinnerDeclaration[]; + challenges: Challenge[]; + totalWinners: number; + totalChallenges: number; + cacheTimestamp: bigint; +} + +export interface CacheStatus { + lastSyncTimestamp: bigint; + cachedWinnersCount: number; + cachedChallengesCount: number; + syncIntervalSeconds: number; +} + +// Unified feed item for UI consumption +export type FeedItemType = "challenge" | "winner" | "second_place" | "third_place" | "participation"; + +export interface ActivityFeedItem { + id: string; + timestamp: bigint; + type: FeedItemType; + challengeId: string; + challengeQuestion?: string; + challengeTopic?: string; + // Winner-specific fields + mainerAddress?: string; // submittedBy principal + ownerAddress?: string; // ownedBy principal + placement?: string; + reward?: bigint; +} + +// ============================================================================ +// Service Class +// ============================================================================ + +export class ActivityFeedService { + private static cache: ActivityFeedResponse | null = null; + private static cacheTimestamp: number = 0; + private static readonly CACHE_DURATION = 30 * 1000; // 30 seconds + + /** + * Check if cache is still valid + */ + private static isCacheValid(): boolean { + if (!this.cacheTimestamp || !this.cache) return false; + return Date.now() - this.cacheTimestamp < this.CACHE_DURATION; + } + + /** + * Clear the cache (useful for manual refresh) + */ + static clearCache(): void { + this.cache = null; + this.cacheTimestamp = 0; + } + + /** + * Fetch the activity feed from the API canister + */ + static async fetchActivityFeed(query: ActivityFeedQuery = {}): Promise { + // Return cached data if valid + if (this.isCacheValid() && this.cache) { + return this.cache; + } + + try { + const storeValue = get(store); + if (!storeValue.apiCanisterActor) { + throw new Error("API canister actor not available"); + } + + // Build query parameters + const queryParam = { + winnersLimit: query.winnersLimit !== undefined ? [BigInt(query.winnersLimit)] : [], + winnersOffset: query.winnersOffset !== undefined ? [BigInt(query.winnersOffset)] : [], + challengesLimit: query.challengesLimit !== undefined ? [BigInt(query.challengesLimit)] : [], + challengesOffset: query.challengesOffset !== undefined ? [BigInt(query.challengesOffset)] : [], + sinceTimestamp: query.sinceTimestamp !== undefined ? [query.sinceTimestamp] : [], + }; + + const result = await storeValue.apiCanisterActor.getActivityFeed(queryParam); + + if ("Ok" in result) { + const response = result.Ok; + + // Transform to our interface + const transformed: ActivityFeedResponse = { + winners: response.winners.map((w: any) => ({ + challengeId: w.challengeId, + finalizedTimestamp: BigInt(w.finalizedTimestamp), + winner: this.transformParticipant(w.winner), + secondPlace: this.transformParticipant(w.secondPlace), + thirdPlace: this.transformParticipant(w.thirdPlace), + participants: w.participants.map((p: any) => this.transformParticipant(p)), + })), + challenges: response.challenges.map((c: any) => ({ + challengeId: c.challengeId, + challengeQuestion: c.challengeQuestion, + challengeTopic: c.challengeTopic, + challengeCreationTimestamp: BigInt(c.challengeCreationTimestamp), + challengeCreatedBy: c.challengeCreatedBy, + challengeStatus: c.challengeStatus, + challengeClosedTimestamp: c.challengeClosedTimestamp?.[0] ? BigInt(c.challengeClosedTimestamp[0]) : undefined, + })), + totalWinners: Number(response.totalWinners), + totalChallenges: Number(response.totalChallenges), + cacheTimestamp: BigInt(response.cacheTimestamp), + }; + + // Update cache + this.cache = transformed; + this.cacheTimestamp = Date.now(); + + return transformed; + } else { + console.error("Error fetching activity feed:", result.Err); + throw new Error(JSON.stringify(result.Err) || "Failed to fetch activity feed"); + } + } catch (error) { + console.error("Error in fetchActivityFeed:", error); + + // Return cached data if available, even if stale + if (this.cache) { + console.log("Returning stale cache due to error"); + return this.cache; + } + + throw error; + } + } + + /** + * Transform a participant entry from the canister response + */ + private static transformParticipant(p: any): ChallengeParticipantEntry { + return { + submissionId: p.submissionId, + submittedBy: p.submittedBy.toString(), + ownedBy: p.ownedBy.toString(), + result: p.result, + reward: { + amount: BigInt(p.reward.amount), + rewardType: p.reward.rewardType, + rewardDetails: p.reward.rewardDetails, + distributed: p.reward.distributed, + distributedTimestamp: p.reward.distributedTimestamp?.[0] ? BigInt(p.reward.distributedTimestamp[0]) : undefined, + }, + }; + } + + /** + * Convert raw API response to unified feed items for the UI + */ + static toFeedItems(response: ActivityFeedResponse): ActivityFeedItem[] { + const items: ActivityFeedItem[] = []; + + // Add challenges + for (const challenge of response.challenges) { + items.push({ + id: `challenge-${challenge.challengeId}`, + timestamp: challenge.challengeCreationTimestamp, + type: "challenge", + challengeId: challenge.challengeId, + challengeQuestion: challenge.challengeQuestion, + challengeTopic: challenge.challengeTopic, + }); + } + + // Add winners from winner declarations + for (const winnerDecl of response.winners) { + // First place + items.push({ + id: `winner-${winnerDecl.challengeId}-${winnerDecl.winner.submissionId}`, + timestamp: winnerDecl.finalizedTimestamp, + type: "winner", + challengeId: winnerDecl.challengeId, + mainerAddress: winnerDecl.winner.submittedBy, + ownerAddress: winnerDecl.winner.ownedBy, + placement: "First Place", + reward: winnerDecl.winner.reward.amount, + }); + + // Second place + items.push({ + id: `second-${winnerDecl.challengeId}-${winnerDecl.secondPlace.submissionId}`, + timestamp: winnerDecl.finalizedTimestamp, + type: "second_place", + challengeId: winnerDecl.challengeId, + mainerAddress: winnerDecl.secondPlace.submittedBy, + ownerAddress: winnerDecl.secondPlace.ownedBy, + placement: "Second Place", + reward: winnerDecl.secondPlace.reward.amount, + }); + + // Third place + if (winnerDecl.thirdPlace && winnerDecl.thirdPlace.submissionId) { + items.push({ + id: `third-${winnerDecl.challengeId}-${winnerDecl.thirdPlace.submissionId}`, + timestamp: winnerDecl.finalizedTimestamp, + type: "third_place", + challengeId: winnerDecl.challengeId, + mainerAddress: winnerDecl.thirdPlace.submittedBy, + ownerAddress: winnerDecl.thirdPlace.ownedBy, + placement: "Third Place", + reward: winnerDecl.thirdPlace.reward.amount, + }); + } + + // Participation rewards + for (const participant of winnerDecl.participants) { + if ("Participated" in participant.result) { + items.push({ + id: `participation-${winnerDecl.challengeId}-${participant.submissionId}`, + timestamp: winnerDecl.finalizedTimestamp, + type: "participation", + challengeId: winnerDecl.challengeId, + mainerAddress: participant.submittedBy, + ownerAddress: participant.ownedBy, + reward: participant.reward.amount, + }); + } + } + } + + // Sort by timestamp (newest first) + items.sort((a, b) => { + const aTime = Number(a.timestamp); + const bTime = Number(b.timestamp); + return bTime - aTime; + }); + + return items; + } + + /** + * Fetch only open challenges from cache + */ + static async fetchOpenChallenges(): Promise { + try { + const storeValue = get(store); + if (!storeValue.apiCanisterActor) { + throw new Error("API canister actor not available"); + } + + const result = await storeValue.apiCanisterActor.getOpenChallengesFromCache(); + + if ("Ok" in result) { + return result.Ok.map((c: any) => ({ + challengeId: c.challengeId, + challengeQuestion: c.challengeQuestion, + challengeTopic: c.challengeTopic, + challengeCreationTimestamp: BigInt(c.challengeCreationTimestamp), + challengeCreatedBy: c.challengeCreatedBy, + challengeStatus: c.challengeStatus, + challengeClosedTimestamp: c.challengeClosedTimestamp?.[0] ? BigInt(c.challengeClosedTimestamp[0]) : undefined, + })); + } else { + console.error("Error fetching open challenges:", result.Err); + throw new Error(JSON.stringify(result.Err) || "Failed to fetch open challenges"); + } + } catch (error) { + console.error("Error in fetchOpenChallenges:", error); + throw error; + } + } + + /** + * Get cache status (for debugging/monitoring) + */ + static async getCacheStatus(): Promise { + try { + const storeValue = get(store); + if (!storeValue.apiCanisterActor) { + throw new Error("API canister actor not available"); + } + + const result = await storeValue.apiCanisterActor.getActivityFeedCacheStatus(); + + if ("Ok" in result) { + return { + lastSyncTimestamp: BigInt(result.Ok.lastSyncTimestamp), + cachedWinnersCount: Number(result.Ok.cachedWinnersCount), + cachedChallengesCount: Number(result.Ok.cachedChallengesCount), + syncIntervalSeconds: Number(result.Ok.syncIntervalSeconds), + }; + } else { + console.error("Error fetching cache status:", result.Err); + return null; + } + } catch (error) { + console.error("Error in getCacheStatus:", error); + return null; + } + } + + /** + * Helper to format timestamp for display + */ + static formatTimestamp(timestamp: bigint): { date: string; time: string } { + // IC timestamps are in nanoseconds, convert to milliseconds + const milliseconds = Number(timestamp) / 1_000_000; + const dateObj = new Date(milliseconds); + + if (isNaN(dateObj.getTime())) { + return { date: "Invalid", time: "Date" }; + } + + const date = dateObj.toLocaleDateString([], { + month: "2-digit", + day: "2-digit", + year: "2-digit", + }); + + const time = dateObj.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return { date, time }; + } + + /** + * Check if a timestamp is within a certain number of days + */ + static isWithinDays(timestamp: bigint, days: number = 3): boolean { + const now = Date.now(); + const itemTime = Number(timestamp) / 1_000_000; // Convert from nanoseconds to milliseconds + const daysDiff = (now - itemTime) / (24 * 60 * 60 * 1000); + return daysDiff <= days && daysDiff >= 0; + } +}