- Drift 21 tables (8 catalog + 11 user + habit_dose_variants + meta_kv) with R1~R10 CHECK constraints and 19 indexes - 8 hand-crafted seed JSON catalogs in app/assets/seed/ (refs 84, protocols 34, methodologies 21, frame_patterns 30, reward_menu_items 30, break_protocols 8, common_frames 5, diet_patterns 5) - 6 domain functions: recommend_variant, compute_streak, validate_frame_level, active_habit_quota, weekly_minimum_ratio, seed_importer (transactional, idempotent) - 4 vertical-slice Riverpod screens: HabitList, HabitCreate, CheckIn, Streak - 31 unit tests passing; flutter analyze clean - OQ-5 streak semantics: missing entry ≠ explicit blank (missing = end of history; only TrackerValue.blank triggers Never-miss-twice) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
68 lines
2.0 KiB
Dart
68 lines
2.0 KiB
Dart
import '../models/habit.dart';
|
|
|
|
class CheckInContext {
|
|
final String? location;
|
|
final String? condition;
|
|
const CheckInContext({this.location, this.condition});
|
|
}
|
|
|
|
enum RecommendReason { exactMatch, partial, fallbackMinimum, fallbackFirst }
|
|
|
|
class VariantPick {
|
|
final HabitDoseVariantModel variant;
|
|
final int score;
|
|
final RecommendReason reason;
|
|
const VariantPick(this.variant, this.score, this.reason);
|
|
}
|
|
|
|
/// fn-recommend-variant: returns best variant for current context.
|
|
/// O(N). Pure function. Returns null if habit has no variants.
|
|
VariantPick? recommendVariant(HabitModel habit, CheckInContext ctx) {
|
|
final variants = habit.doseVariants;
|
|
if (variants.isEmpty) return null;
|
|
|
|
final scored = variants
|
|
.map((v) => MapEntry(v, _scoreVariant(v, ctx)))
|
|
.toList();
|
|
|
|
// Stable sort: score desc, then sortOrder asc.
|
|
scored.sort((a, b) {
|
|
final byScore = b.value.compareTo(a.value);
|
|
if (byScore != 0) return byScore;
|
|
return a.key.sortOrder.compareTo(b.key.sortOrder);
|
|
});
|
|
|
|
final best = scored.first;
|
|
if (best.value > 0) {
|
|
final reason =
|
|
best.value >= 4 ? RecommendReason.exactMatch : RecommendReason.partial;
|
|
return VariantPick(best.key, best.value, reason);
|
|
}
|
|
|
|
// Fallback: first is_minimum variant (by sortOrder).
|
|
final minimum = variants
|
|
.where((v) => v.isMinimum)
|
|
.fold<HabitDoseVariantModel?>(null,
|
|
(a, v) => a == null || v.sortOrder < a.sortOrder ? v : a);
|
|
if (minimum != null) {
|
|
return VariantPick(minimum, 0, RecommendReason.fallbackMinimum);
|
|
}
|
|
// No is_minimum — fall back to first by sortOrder.
|
|
final first = [...variants]..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
|
return VariantPick(first.first, 0, RecommendReason.fallbackFirst);
|
|
}
|
|
|
|
int _scoreVariant(HabitDoseVariantModel v, CheckInContext ctx) {
|
|
var s = 0;
|
|
if (ctx.location != null && v.contextTags.contains(ctx.location)) {
|
|
s += 2;
|
|
}
|
|
if (ctx.condition != null && v.conditionTags.contains(ctx.condition)) {
|
|
s += 2;
|
|
}
|
|
if (v.isMinimum && ctx.condition == '나쁨') {
|
|
s += 1;
|
|
}
|
|
return s;
|
|
}
|