HOT ARCHIVE 정렬 기준

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:10 +09:00
parent cf608a1d99
commit e7010010bb
2 changed files with 163 additions and 11 deletions
@@ -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
final policy = (await policyF).data() ?? const <String, dynamic>{};
final campaigns = await _hotArchive(
(await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid']))
.toList();
.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 <String, dynamic>{};
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<List<QueryDocumentSnapshot<Map<String, dynamic>>>> _hotArchive(
List<QueryDocumentSnapshot<Map<String, dynamic>>> docs,
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>? 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<String> 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<Map<String, dynamic>> popularInsides(
+52
View File
@@ -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'],
);
});
}