diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 301ffce..991f1f3 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -1,4 +1,5 @@ -import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; +import 'package:cloud_firestore/cloud_firestore.dart' + show QueryDocumentSnapshot, Timestamp; import '../../core/data/backend.dart'; import '../../core/data/invite_code.dart'; @@ -152,14 +153,13 @@ class FirestoreHomeRepository implements HomeRepository { .orderBy('rank') .limit(6) .get(); - // HOT ARCHIVE 캐러셀 — 명세 §2.114-6 「최대 5개」. 차단한 개설자의 캠페인은 건너뛰므로 - // 열 건을 읽어 다섯을 채운다. ponytail: 정렬은 모금액 순 하나(명세의 「달성 임박 > 기여 급증 > - // 신규」 3단 정렬은 기여 급증 집계가 없어 아직 못 한다). + // HOT ARCHIVE 캐러셀 — Figma 홈 주석 `2097:16415` 「최대 5개 소팅(목표 달성 임박 건 > 실시간 + // 기여 급증 건 > 신규 등록 건 순)」 · 별첨 B 「HOT ARCHIVE」. 정렬은 아래 [hotArchiveOrder]. + // ponytail: 모금 중 30건 안에서 고른다. 모금 중 캠페인이 이보다 많아지면 서버 집계 필드로 옮긴다. final campaignF = db .collection('campaigns') .where('status', isEqualTo: 'funding') - .orderBy('raisedPop', descending: true) - .limit(10) + .limit(30) .get(); // 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만. // ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다. @@ -209,9 +209,13 @@ class FirestoreHomeRepository implements HomeRepository { final progress = (await progressF).docs; final briefings = (await briefingsF).docs; final blocked = {for (final d in (await blocksF).docs) d.id}; - final campaigns = (await campaignF).docs - .where((d) => !blocked.contains(d.data()['creatorUid'])) - .toList(); + final policy = (await policyF).data() ?? const {}; + final campaigns = await _hotArchive( + (await campaignF).docs + .where((d) => !blocked.contains(d.data()['creatorUid'])) + .toList(), + policy, + ); final insideDocs = { for (final doc in (await insidesF).docs) doc.data(): doc.id, }; @@ -221,7 +225,6 @@ class FirestoreHomeRepository implements HomeRepository { ]; final progressById = {for (final doc in progress) doc.id: doc.data()}; - final policy = (await policyF).data() ?? const {}; return HomeSnapshot( popBalance: wallet.intOr('activityBalance'), @@ -235,7 +238,7 @@ class FirestoreHomeRepository implements HomeRepository { _quest(doc.id, doc.data(), progressById[doc.id], policy), ], briefings: [for (final doc in briefings) ?_briefing(doc.data())], - campaigns: [for (final d in campaigns.take(5)) _campaign(d.id, d.data())], + campaigns: [for (final d in campaigns) _campaign(d.id, d.data())], insides: [ for (final (id, data) in insides) insideQuestionFrom(id, data, uid), ], @@ -393,6 +396,47 @@ class FirestoreHomeRepository implements HomeRepository { ); } + /// HOT ARCHIVE 5건 — 「목표 달성 임박 > 실시간 기여 급증 > 신규 등록」. + /// + /// 급증은 최근 [HotArchiveRule.surgeWindow] 안의 후원 합(`campaigns/{id}/donations`)으로 잰다. + /// 임박 묶음에 든 캠페인은 급증을 셀 필요가 없어 읽지 않는다. + Future>>> _hotArchive( + List>> docs, + Map policy, + ) async { + final rule = HotArchiveRule.from(policy); + final since = Timestamp.fromDate(DateTime.now().subtract(rule.surgeWindow)); + final candidates = await Future.wait([ + for (final d in docs) + () async { + final goal = d.data().intOr('targetPop', d.data().intOr('goalPop')); + final raised = d.data().intOr('raisedPop'); + final percent = goal == 0 ? 0.0 : raised * 100 / goal; + var recent = 0; + if (percent < rule.imminentPercent) { + final snap = await d.reference + .collection('donations') + .where('createdAt', isGreaterThanOrEqualTo: since) + .get(); + for (final x in snap.docs) { + recent += x.data().intOr('amount'); + } + } + return ( + id: d.id, + percent: percent, + recentPop: recent, + createdAt: d.data().dateOr( + 'createdAt', + DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + }(), + ]); + final byId = {for (final d in docs) d.id: d}; + return [for (final id in hotArchiveOrder(candidates, rule)) byId[id]!]; + } + CampaignHighlight _campaign(String id, Map data) { // 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다. final goal = data.intOr('targetPop', data.intOr('goalPop')); @@ -410,6 +454,62 @@ class FirestoreHomeRepository implements HomeRepository { } } +/// HOT ARCHIVE 정렬의 기준값 — `config/policy.hotArchive`. +/// +/// **임시값 — 회신 대기.** 「목표 달성 임박」의 달성률 기준과 「실시간 기여 급증」의 측정 구간은 +/// Figma 주석 `2097:16415` · 별첨 B 어디에도 수치가 없다(해석 확인 요청 영역D). 운영값 자리에 두고 +/// 회신을 받으면 값만 바꾼다. 기본값 80% · 60분은 회신 전 임시값이다. +class HotArchiveRule { + const HotArchiveRule({ + this.imminentPercent = 80, + this.surgeWindow = const Duration(minutes: 60), + }); + + factory HotArchiveRule.from(Map? policy) { + final raw = policy?['hotArchive']; + if (raw is! Map) return const HotArchiveRule(); + final percent = raw['imminentPercent']; + final minutes = raw['surgeWindowMinutes']; + return HotArchiveRule( + imminentPercent: percent is num && percent > 0 ? percent.toDouble() : 80, + surgeWindow: Duration( + minutes: minutes is int && minutes > 0 ? minutes : 60, + ), + ); + } + + /// 이 달성률(%) 이상이면 「목표 달성 임박」. + final double imminentPercent; + + /// 「실시간 기여 급증」을 재는 최근 구간. + final Duration surgeWindow; +} + +/// HOT ARCHIVE 최대 5건의 순서 — 「목표 달성 임박 건 > 실시간 기여 급증 건 > 신규 등록 건」. +/// +/// 임박(달성률 ≥ 기준)은 달성률 높은 순, 급증(최근 구간 후원 > 0)은 최근 후원 많은 순, +/// 나머지는 신규 등록 순이다. +List hotArchiveOrder( + List<({String id, double percent, int recentPop, DateTime createdAt})> all, + HotArchiveRule rule, +) { + final imminent = all.where((c) => c.percent >= rule.imminentPercent).toList() + ..sort((a, b) => b.percent.compareTo(a.percent)); + final surging = + all + .where((c) => c.percent < rule.imminentPercent && c.recentPop > 0) + .toList() + ..sort((a, b) => b.recentPop.compareTo(a.recentPop)); + final rest = + all + .where((c) => c.percent < rule.imminentPercent && c.recentPop <= 0) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return [ + for (final c in [...imminent, ...surging, ...rest].take(5)) c.id, + ]; +} + /// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순. /// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다. List> popularInsides( diff --git a/app/test/hot_archive_order_test.dart b/app/test/hot_archive_order_test.dart new file mode 100644 index 0000000..0d704b0 --- /dev/null +++ b/app/test/hot_archive_order_test.dart @@ -0,0 +1,52 @@ +import 'package:fancake/features/home/home_repository_firestore.dart' + show HotArchiveRule, hotArchiveOrder; +import 'package:flutter_test/flutter_test.dart'; + +/// HOT ARCHIVE — Figma `2097:16415` 「목표 달성 임박 > 실시간 기여 급증 > 신규 등록, 최대 5개」. +void main() { + final t0 = DateTime(2026, 10, 1); + ({String id, double percent, int recentPop, DateTime createdAt}) c( + String id, + double percent, + int recent, + int day, + ) => ( + id: id, + percent: percent, + recentPop: recent, + createdAt: t0.add(Duration(days: day)), + ); + + test('임박 → 급증 → 신규 순이고 5건까지만', () { + final order = hotArchiveOrder([ + c('old', 10, 0, 0), + c('new', 10, 0, 5), + c('surge-small', 20, 100, 1), + c('surge-big', 30, 900, 0), + c('imminent-90', 90, 0, 0), + c('imminent-99', 99, 0, 0), + c('rich-but-slow', 70, 0, 6), + ], const HotArchiveRule()); + expect(order, [ + 'imminent-99', + 'imminent-90', + 'surge-big', + 'surge-small', + 'rich-but-slow', + ]); + }); + + test('기준값은 운영값(config/policy.hotArchive)을 따르고 없으면 임시값', () { + expect(HotArchiveRule.from(null).imminentPercent, 80); + expect(HotArchiveRule.from(null).surgeWindow, const Duration(minutes: 60)); + final rule = HotArchiveRule.from({ + 'hotArchive': {'imminentPercent': 95, 'surgeWindowMinutes': 30}, + }); + expect(rule.imminentPercent, 95); + expect(rule.surgeWindow, const Duration(minutes: 30)); + expect( + hotArchiveOrder([c('a', 90, 0, 0), c('b', 10, 5, 0)], rule), + ['b', 'a'], + ); + }); +}