HOT ARCHIVE 정렬 기준
This commit is contained in:
@@ -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/backend.dart';
|
||||||
import '../../core/data/invite_code.dart';
|
import '../../core/data/invite_code.dart';
|
||||||
@@ -152,14 +153,13 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
.orderBy('rank')
|
.orderBy('rank')
|
||||||
.limit(6)
|
.limit(6)
|
||||||
.get();
|
.get();
|
||||||
// HOT ARCHIVE 캐러셀 — 명세 §2.114-6 「최대 5개」. 차단한 개설자의 캠페인은 건너뛰므로
|
// HOT ARCHIVE 캐러셀 — Figma 홈 주석 `2097:16415` 「최대 5개 소팅(목표 달성 임박 건 > 실시간
|
||||||
// 열 건을 읽어 다섯을 채운다. ponytail: 정렬은 모금액 순 하나(명세의 「달성 임박 > 기여 급증 >
|
// 기여 급증 건 > 신규 등록 건 순)」 · 별첨 B 「HOT ARCHIVE」. 정렬은 아래 [hotArchiveOrder].
|
||||||
// 신규」 3단 정렬은 기여 급증 집계가 없어 아직 못 한다).
|
// ponytail: 모금 중 30건 안에서 고른다. 모금 중 캠페인이 이보다 많아지면 서버 집계 필드로 옮긴다.
|
||||||
final campaignF = db
|
final campaignF = db
|
||||||
.collection('campaigns')
|
.collection('campaigns')
|
||||||
.where('status', isEqualTo: 'funding')
|
.where('status', isEqualTo: 'funding')
|
||||||
.orderBy('raisedPop', descending: true)
|
.limit(30)
|
||||||
.limit(10)
|
|
||||||
.get();
|
.get();
|
||||||
// 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만.
|
// 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만.
|
||||||
// ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다.
|
// ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다.
|
||||||
@@ -209,9 +209,13 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
final progress = (await progressF).docs;
|
final progress = (await progressF).docs;
|
||||||
final briefings = (await briefingsF).docs;
|
final briefings = (await briefingsF).docs;
|
||||||
final blocked = {for (final d in (await blocksF).docs) d.id};
|
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']))
|
.where((d) => !blocked.contains(d.data()['creatorUid']))
|
||||||
.toList();
|
.toList(),
|
||||||
|
policy,
|
||||||
|
);
|
||||||
final insideDocs = {
|
final insideDocs = {
|
||||||
for (final doc in (await insidesF).docs) doc.data(): doc.id,
|
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 progressById = {for (final doc in progress) doc.id: doc.data()};
|
||||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
|
||||||
|
|
||||||
return HomeSnapshot(
|
return HomeSnapshot(
|
||||||
popBalance: wallet.intOr('activityBalance'),
|
popBalance: wallet.intOr('activityBalance'),
|
||||||
@@ -235,7 +238,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
_quest(doc.id, doc.data(), progressById[doc.id], policy),
|
_quest(doc.id, doc.data(), progressById[doc.id], policy),
|
||||||
],
|
],
|
||||||
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
|
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: [
|
insides: [
|
||||||
for (final (id, data) in insides) insideQuestionFrom(id, data, uid),
|
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) {
|
CampaignHighlight _campaign(String id, Map<String, dynamic> data) {
|
||||||
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
|
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
|
||||||
final goal = data.intOr('targetPop', data.intOr('goalPop'));
|
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`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
|
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
|
||||||
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
|
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
|
||||||
List<Map<String, dynamic>> popularInsides(
|
List<Map<String, dynamic>> popularInsides(
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user