Question FIFO

This commit is contained in:
Nathan Anderson 2026-06-03 04:21:42 -05:00
parent 609317bc35
commit 0c4c72f9c9
14 changed files with 1364 additions and 291 deletions

View File

@ -5,10 +5,11 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Deterministic irregular polygon + palette derived from a question UUID. /// Deterministic 3D crystal mesh + palette derived from a question UUID.
/// ///
/// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to /// Parses the canonical 16-byte UUID (hyphens optional), builds a perturbed
/// polar vertices so the same id always produces the same glyph. /// icosahedron with optional stellated facets, and paints it with depth-sorted
/// shaded faces so the same id always produces the same glyph.
class GuidGlyphShape { class GuidGlyphShape {
GuidGlyphShape._(this.bytes); GuidGlyphShape._(this.bytes);
@ -23,25 +24,155 @@ class GuidGlyphShape {
return GuidGlyphShape._(_hashTo16Bytes(guid)); return GuidGlyphShape._(_hashTo16Bytes(guid));
} }
/// Vertex count in [5, 10], from the first UUID byte. static const double _phi = 1.618033988749895;
int get vertexCount => 5 + (bytes[0] % 6);
/// Unit-circle offsets (rough polygon); multiply by size when painting. static final List<_Vec3> _icosahedronBase = <_Vec3>[
List<Offset> unitVertices() { _Vec3(-1, _phi, 0),
final int n = vertexCount; _Vec3(1, _phi, 0),
final List<Offset> points = <Offset>[]; _Vec3(-1, -_phi, 0),
double angle = (bytes[1] / 255) * math.pi * 2; _Vec3(1, -_phi, 0),
_Vec3(0, -1, _phi),
_Vec3(0, 1, _phi),
_Vec3(0, -1, -_phi),
_Vec3(0, 1, -_phi),
_Vec3(_phi, 0, -1),
_Vec3(_phi, 0, 1),
_Vec3(-_phi, 0, -1),
_Vec3(-_phi, 0, 1),
].map((v) => v.normalized(scale: 0.88)).toList(growable: false);
for (int i = 0; i < n; i++) { static const List<List<int>> _icosahedronFaces = <List<int>>[
final int rIndex = (i * 2) % 16; <int>[0, 11, 5],
final int aIndex = (i * 2 + 1) % 16; <int>[0, 5, 1],
final double radius = 0.42 + (bytes[rIndex] / 255) * 0.52; <int>[0, 1, 7],
angle += (math.pi * 2 / n) + ((bytes[aIndex] / 255) - 0.5) * 0.95; <int>[0, 7, 10],
points.add( <int>[0, 10, 11],
Offset(math.cos(angle) * radius, math.sin(angle) * radius), <int>[1, 5, 9],
<int>[5, 11, 4],
<int>[11, 10, 2],
<int>[10, 7, 6],
<int>[7, 1, 8],
<int>[3, 9, 4],
<int>[3, 4, 2],
<int>[3, 2, 6],
<int>[3, 6, 8],
<int>[3, 8, 9],
<int>[4, 9, 5],
<int>[2, 4, 11],
<int>[6, 2, 10],
<int>[8, 6, 7],
<int>[9, 8, 1],
];
double _byte(int index) => bytes[index % 16] / 255;
/// Base orientation baked into every guid (before slider warp).
({double x, double y, double z}) get _baseEuler => (
x: (_byte(1) - 0.5) * math.pi * 0.9,
y: (_byte(2) - 0.5) * math.pi * 2,
z: (_byte(3) - 0.5) * math.pi * 0.55,
);
_Vec3 get _lightDirection => _Vec3(
0.25 + (_byte(10) - 0.5) * 0.5,
-0.45 + (_byte(11) - 0.5) * 0.35,
0.65 + (_byte(12) - 0.5) * 0.4,
).normalized();
/// Isometric projection mix from guid bytes.
({double xz, double lift}) get _projection => (
xz: 0.42 + _byte(13) * 0.22,
lift: 0.28 + _byte(14) * 0.18,
);
List<_Vec3> _unitVerticesRaw() {
return List<_Vec3>.generate(12, (int i) {
final _Vec3 base = _icosahedronBase[i];
final double radial =
0.76 + _byte(i) * 0.42 + (_byte(i + 7) - 0.5) * 0.14;
final _Vec3 wobble = _Vec3(
(_byte(i + 3) - 0.5) * 0.22,
(_byte(i + 5) - 0.5) * 0.22,
(_byte(i + 9) - 0.5) * 0.22,
);
return (base + wobble).normalized(scale: radial);
});
}
_Vec3 _rotateEuler(_Vec3 v, ({double x, double y, double z}) euler) {
var out = v.rotateX(euler.x).rotateY(euler.y).rotateZ(euler.z);
return out;
}
/// Stellated icosahedron: some faces gain a pyramid apex for extra complexity.
({List<_Vec3> vertices, List<_MeshFace> faces}) _buildMesh(
List<_Vec3> vertices,
List<List<int>> triangles,
) {
final List<_Vec3> verts = List<_Vec3>.from(vertices);
final List<_MeshFace> faces = <_MeshFace>[
for (int fi = 0; fi < triangles.length; fi++)
_MeshFace(
fi,
triangles[fi][0],
triangles[fi][1],
triangles[fi][2],
),
];
for (int fi = 0; fi < triangles.length; fi++) {
if ((bytes[(fi + 4) % 16] % 3) == 0) {
continue;
}
final List<int> tri = triangles[fi];
final _Vec3 v0 = verts[tri[0]];
final _Vec3 v1 = verts[tri[1]];
final _Vec3 v2 = verts[tri[2]];
final _Vec3 centroid = (v0 + v1 + v2) * (1 / 3);
final _Vec3 normal = _Vec3.cross(v1 - v0, v2 - v0).normalized();
final double spike = 0.18 + _byte(fi + 6) * 0.38;
final int apex = verts.length;
verts.add(centroid + normal * spike);
faces[fi] = _MeshFace(fi, tri[0], tri[1], apex);
faces.add(_MeshFace(fi + 100, tri[1], tri[2], apex));
faces.add(_MeshFace(fi + 200, tri[2], tri[0], apex));
}
return (vertices: verts, faces: faces);
}
({List<_Vec3> vertices, List<_MeshFace> faces}) displayMesh(num displayValue) {
final double t = displayT(displayValue);
final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0;
final ({double x, double y, double z}) euler = (
x: _baseEuler.x + t * (0.32 + _byte(4) * 0.28) * spinSign,
y: _baseEuler.y + t * (0.55 + _byte(5) * 0.35) * spinSign,
z: _baseEuler.z + t * (0.2 + _byte(6) * 0.18),
);
final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
_buildMesh(_unitVerticesRaw(), _icosahedronFaces);
final List<_Vec3> rotated = <_Vec3>[
for (final _Vec3 v in mesh.vertices) _rotateEuler(v, euler),
];
return (vertices: rotated, faces: mesh.faces);
}
/// Projects a unit-space vertex to 2D (isometric-style).
Offset _projectUnit(_Vec3 v) {
final ({double xz, double lift}) proj = _projection;
return Offset(
v.x - v.z * proj.xz,
v.y + v.z * proj.lift + v.x * proj.lift * 0.12,
); );
} }
return points;
List<Offset> displayUnitVertices(num displayValue) {
final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
displayMesh(displayValue);
return mesh.vertices.map(_projectUnit).toList(growable: false);
} }
/// Fill color: warm accent band so glyphs stay on-brand but distinct. /// Fill color: warm accent band so glyphs stay on-brand but distinct.
@ -50,41 +181,13 @@ class GuidGlyphShape {
return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor(); return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor();
} }
Color strokeColor() => HSLColor.fromColor(fillColor()).withLightness(0.38).toColor(); Color strokeColor() =>
HSLColor.fromColor(fillColor()).withLightness(0.38).toColor();
/// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10]. /// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10].
static double displayT(num displayValue) => static double displayT(num displayValue) =>
(displayValue / 10).clamp(-1.0, 1.0).toDouble(); (displayValue / 10).clamp(-1.0, 1.0).toDouble();
/// Display-only vertex warp; same [guid] + [displayValue] always matches.
List<Offset> displayUnitVertices(num displayValue) {
final double t = displayT(displayValue);
final List<Offset> base = unitVertices();
final int n = base.length;
final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0;
final double spin = t *
math.pi *
(0.28 + (bytes[4] / 255) * 0.22) *
spinSign;
final double cosR = math.cos(spin);
final double sinR = math.sin(spin);
final double stretchY = 1 + t * (0.38 + (bytes[5] / 255) * 0.2);
final double stretchX = 1 - t * 0.14 * (bytes[6] / 255);
final List<Offset> out = <Offset>[];
for (int i = 0; i < n; i++) {
final Offset p = base[i];
final double bulge =
1 + t * ((bytes[(i * 3 + 7) % 16] / 255) - 0.5) * 0.55;
final double x = p.dx * stretchX * bulge;
final double y = p.dy * stretchY * bulge;
out.add(Offset(x * cosR - y * sinR, x * sinR + y * cosR));
}
return out;
}
/// True when the slider is at neutral zero (no directional guess). /// True when the slider is at neutral zero (no directional guess).
static bool isNeutralZero(num displayValue) => displayValue == 0; static bool isNeutralZero(num displayValue) => displayValue == 0;
@ -116,7 +219,22 @@ class GuidGlyphShape {
return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!; return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!;
} }
/// Furthest unit-circle distance after [displayUnitVertices] warp (for glow sizing). Color _shadeFaceColor(Color base, double shade, num displayValue) {
if (isNeutralZero(displayValue)) {
return Color.lerp(
const Color(0xFFE8EEF4),
const Color(0xFFF8FCFF),
shade,
)!;
}
final HSLColor hsl = HSLColor.fromColor(base);
return hsl
.withLightness((hsl.lightness * (0.55 + shade * 0.5)).clamp(0.18, 0.82))
.withSaturation((hsl.saturation * (0.75 + shade * 0.35)).clamp(0.2, 1))
.toColor();
}
/// Furthest 2D extent after projection (for glow sizing).
double displayMaxUnitRadius(num displayValue) { double displayMaxUnitRadius(num displayValue) {
double maxR = 0; double maxR = 0;
for (final Offset p in displayUnitVertices(displayValue)) { for (final Offset p in displayUnitVertices(displayValue)) {
@ -135,7 +253,7 @@ class GuidGlyphShape {
double displayStrokeWidth(num displayValue) { double displayStrokeWidth(num displayValue) {
final double t = displayT(displayValue); final double t = displayT(displayValue);
return (2.5 + t.abs() * 1.2 + (bytes[8] / 255) * 0.4).clamp(2.0, 4.0); return (1.8 + t.abs() * 0.9 + (bytes[8] / 255) * 0.35).clamp(1.4, 3.2);
} }
static Uint8List? tryParseUuidBytes(String guid) { static Uint8List? tryParseUuidBytes(String guid) {
@ -166,6 +284,76 @@ class GuidGlyphShape {
} }
} }
class _Vec3 {
const _Vec3(this.x, this.y, this.z);
final double x;
final double y;
final double z;
double get length => math.sqrt(x * x + y * y + z * z);
_Vec3 normalized({double scale = 1}) {
final double len = length;
if (len < 1e-9) {
return _Vec3(0, scale, 0);
}
final double s = scale / len;
return _Vec3(x * s, y * s, z * s);
}
_Vec3 operator +(_Vec3 other) => _Vec3(x + other.x, y + other.y, z + other.z);
_Vec3 operator -(_Vec3 other) => _Vec3(x - other.x, y - other.y, z - other.z);
_Vec3 operator *(double scalar) => _Vec3(x * scalar, y * scalar, z * scalar);
_Vec3 rotateX(double angle) {
final double c = math.cos(angle);
final double s = math.sin(angle);
return _Vec3(x, y * c - z * s, y * s + z * c);
}
_Vec3 rotateY(double angle) {
final double c = math.cos(angle);
final double s = math.sin(angle);
return _Vec3(x * c + z * s, y, -x * s + z * c);
}
_Vec3 rotateZ(double angle) {
final double c = math.cos(angle);
final double s = math.sin(angle);
return _Vec3(x * c - y * s, x * s + y * c, z);
}
static double dot(_Vec3 a, _Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
static _Vec3 cross(_Vec3 a, _Vec3 b) => _Vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x,
);
}
class _MeshFace {
const _MeshFace(this.id, this.i0, this.i1, this.i2);
final int id;
final int i0;
final int i1;
final int i2;
double averageDepth(List<_Vec3> vertices) =>
(vertices[i0].z + vertices[i1].z + vertices[i2].z) / 3;
_Vec3 normal(List<_Vec3> vertices) {
final _Vec3 v0 = vertices[i0];
final _Vec3 v1 = vertices[i1];
final _Vec3 v2 = vertices[i2];
return _Vec3.cross(v1 - v0, v2 - v0).normalized();
}
}
/// Painted glyph for a question id (replaces the fixed red dot). /// Painted glyph for a question id (replaces the fixed red dot).
class QuestionGuidGlyph extends StatelessWidget { class QuestionGuidGlyph extends StatelessWidget {
const QuestionGuidGlyph({ const QuestionGuidGlyph({
@ -188,10 +376,8 @@ class QuestionGuidGlyph extends StatelessWidget {
final Color glow = shape.displayGlowColor(displayValue); final Color glow = shape.displayGlowColor(displayValue);
final double strokeWidth = shape.displayStrokeWidth(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue);
final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2); final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2);
// Slightly larger bounds when warped / off-zero so the halo follows the shape. final double paintedRadius = baseRadius * (1 + intensity * 0.12);
final double paintedRadius = baseRadius * (1 + intensity * 0.1);
// Shadow box matches painted extent; blur/spread grow with that diameter.
final double bodyDiameter = paintedRadius * 2; final double bodyDiameter = paintedRadius * 2;
final double blur = bodyDiameter * (0.2 + intensity * 0.16); final double blur = bodyDiameter * (0.2 + intensity * 0.16);
final double spread = bodyDiameter * (0.03 + intensity * 0.06); final double spread = bodyDiameter * (0.03 + intensity * 0.06);
@ -250,36 +436,94 @@ class _GuidGlyphPainter extends CustomPainter {
final double maxUnitR = shape.displayMaxUnitRadius(displayValue); final double maxUnitR = shape.displayMaxUnitRadius(displayValue);
final double scale = paintedRadius / maxUnitR; final double scale = paintedRadius / maxUnitR;
final double strokeWidth = shape.displayStrokeWidth(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue);
final List<Offset> unit = shape.displayUnitVertices(displayValue);
final Path path = Path(); final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
for (int i = 0; i < unit.length; i++) { shape.displayMesh(displayValue);
final Offset p = center + Offset(unit[i].dx * scale, unit[i].dy * scale); final List<Offset> projected = mesh.vertices
if (i == 0) { .map((v) => center + shape._projectUnit(v) * scale)
path.moveTo(p.dx, p.dy); .toList(growable: false);
} else {
path.lineTo(p.dx, p.dy);
}
}
path.close();
final Color fill = shape.displayFillColor(displayValue); final List<_MeshFace> sorted = List<_MeshFace>.from(mesh.faces)
final Color stroke = shape.displayStrokeColor(displayValue); ..sort(
(a, b) =>
a.averageDepth(mesh.vertices).compareTo(b.averageDepth(mesh.vertices)),
);
final Color baseFill = shape.displayFillColor(displayValue);
final _Vec3 light = shape._lightDirection;
for (final _MeshFace face in sorted) {
final _Vec3 normal = face.normal(mesh.vertices);
final double ndotl =
_Vec3.dot(normal, light).clamp(-1.0, 1.0) * 0.5 + 0.5;
final double shade = (0.28 + ndotl * 0.72).clamp(0.22, 1.0);
final Path path = Path()
..moveTo(projected[face.i0].dx, projected[face.i0].dy)
..lineTo(projected[face.i1].dx, projected[face.i1].dy)
..lineTo(projected[face.i2].dx, projected[face.i2].dy)
..close();
canvas.drawPath( canvas.drawPath(
path, path,
Paint() Paint()
..color = fill ..color = shape._shadeFaceColor(baseFill, shade, displayValue)
..style = PaintingStyle.fill, ..style = PaintingStyle.fill,
); );
canvas.drawPath( }
path,
Paint() final Color edgeColor = shape.displayStrokeColor(displayValue);
..color = stroke final Paint edgePaint = Paint()
..color = edgeColor.withValues(alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.55 : 0.75)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = strokeWidth ..strokeWidth = strokeWidth
..strokeJoin = StrokeJoin.round, ..strokeJoin = StrokeJoin.round;
final Set<String> drawnEdges = <String>{};
for (final _MeshFace face in mesh.faces) {
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i0, face.i1);
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i1, face.i2);
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i2, face.i0);
}
// Specular highlight on the front-most face.
if (sorted.isNotEmpty) {
final _MeshFace front = sorted.last;
final Offset highlight = Offset(
(projected[front.i0].dx +
projected[front.i1].dx +
projected[front.i2].dx) /
3,
(projected[front.i0].dy +
projected[front.i1].dy +
projected[front.i2].dy) /
3,
); );
canvas.drawCircle(
highlight,
(scale * 0.09).clamp(2.0, 6.0),
Paint()
..color = Colors.white.withValues(
alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.35 : 0.28,
),
);
}
}
void _strokeEdge(
Canvas canvas,
List<Offset> projected,
Paint paint,
Set<String> drawnEdges,
int a,
int b,
) {
final int lo = a < b ? a : b;
final int hi = a < b ? b : a;
final String key = '$lo-$hi';
if (!drawnEdges.add(key)) {
return;
}
canvas.drawLine(projected[lo], projected[hi], paint);
} }
@override @override

View File

@ -0,0 +1,12 @@
import 'incoming_question.dart';
/// Outcome of deferring (skipping) a question to the back of the FIFO queue.
class QuestionDeferResult {
const QuestionDeferResult({
required this.unansweredCount,
this.nextQuestion,
});
final int unansweredCount;
final IncomingQuestion? nextQuestion;
}

View File

@ -279,7 +279,7 @@ class _QuestionEnvelopeButton extends StatelessWidget {
children: <Widget>[ children: <Widget>[
IconButton( IconButton(
onPressed: onPressed, onPressed: onPressed,
tooltip: count > 1 ? '$count questions' : 'New question', tooltip: count == 1 ? '1 question' : '$count questions',
icon: const Icon(Icons.mail_outline, size: 22), icon: const Icon(Icons.mail_outline, size: 22),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.accent.withValues(alpha: 0.15), backgroundColor: AppColors.accent.withValues(alpha: 0.15),
@ -289,7 +289,7 @@ class _QuestionEnvelopeButton extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
), ),
if (count >= 2) if (count >= 1)
Positioned( Positioned(
top: 4, top: 4,
right: 0, right: 0,

View File

@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_summary.dart'; import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_defer_result.dart';
import '../models/question_submit_result.dart'; import '../models/question_submit_result.dart';
import 'auth_service.dart'; import 'auth_service.dart';
@ -120,7 +121,7 @@ class QuestionsApiService {
); );
} }
Future<int?> deferQuestion({required String questionId}) async { Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
final String? token = await AuthService.instance.getIdToken(); final String? token = await AuthService.instance.getIdToken();
if (token == null) { if (token == null) {
return null; return null;
@ -139,7 +140,14 @@ class QuestionsApiService {
final Map<String, dynamic> body = final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>; jsonDecode(response.body) as Map<String, dynamic>;
return (body['unansweredCount'] as num?)?.toInt(); final Map<String, dynamic>? nextQuestionJson =
body['nextQuestion'] as Map<String, dynamic>?;
return QuestionDeferResult(
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
nextQuestion: nextQuestionJson == null
? null
: IncomingQuestion.fromJson(nextQuestionJson),
);
} }
Future<GuessScoreSummary?> fetchGuessScoreSummary() async { Future<GuessScoreSummary?> fetchGuessScoreSummary() async {

View File

@ -6,6 +6,7 @@ import 'package:signalr_netcore/signalr_client.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_summary.dart'; import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_defer_result.dart';
import '../models/question_submit_result.dart'; import '../models/question_submit_result.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'questions_api_service.dart'; import 'questions_api_service.dart';
@ -173,26 +174,50 @@ class QuestionsHubService {
return; return;
} }
final String skippedQuestionId = question.id;
questionActionBusy.value = true; questionActionBusy.value = true;
try { try {
final List<IncomingQuestion> queue = List<IncomingQuestion>.from( final QuestionDeferResult? result = await _api.deferQuestion(
questionQueue.value, questionId: skippedQuestionId,
); );
if (queue.isEmpty) { if (result == null) {
return; return;
} }
final IncomingQuestion current = queue.removeAt(0);
queue.add(current);
final int? serverCount = await _api.deferQuestion(questionId: current.id); if (result.unansweredCount == 0) {
if (serverCount != null) { _clearPendingUi();
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered(); return;
questionQueue.value = refreshed.isNotEmpty ? refreshed : queue;
_syncPendingFromQueue(serverCount);
} else {
questionQueue.value = queue;
_syncPendingFromQueue(queue.length);
} }
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
if (refreshed.isEmpty) {
if (result.nextQuestion != null) {
_setActiveQuestion(
result.nextQuestion!,
unansweredCount: result.unansweredCount,
);
} else {
_clearPendingUi();
}
return;
}
IncomingQuestion next = refreshed.first;
if (refreshed.length > 1 && next.id == skippedQuestionId) {
next = refreshed.firstWhere(
(IncomingQuestion q) => q.id != skippedQuestionId,
orElse: () => refreshed.first,
);
} else if (result.nextQuestion != null &&
result.nextQuestion!.id != skippedQuestionId) {
next = result.nextQuestion!;
}
_setActiveQuestion(
next,
unansweredCount: result.unansweredCount,
queue: refreshed,
);
} finally { } finally {
questionActionBusy.value = false; questionActionBusy.value = false;
} }
@ -283,17 +308,6 @@ class QuestionsHubService {
return true; return true;
} }
void _syncPendingFromQueue(int count) {
final List<IncomingQuestion> queue = questionQueue.value;
if (queue.isEmpty || count == 0) {
_clearPendingUi();
return;
}
pendingQuestion.value = queue.first;
pendingQuestionCount.value = count;
hasPendingQuestion.value = true;
}
/// Clears pending question UI; keeps the SignalR connection alive. /// Clears pending question UI; keeps the SignalR connection alive.
void _clearPendingUi() { void _clearPendingUi() {
hasPendingQuestion.value = false; hasPendingQuestion.value = false;

View File

@ -8,6 +8,11 @@ import 'package:flutter/services.dart';
import '../guid/guid_glyph_shape.dart'; import '../guid/guid_glyph_shape.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
const double _holdEdgeButtonWidth = 58;
/// Equal left/right margin for the slider; edge buttons sit in the gutters.
const double _centerSideInset = _holdEdgeButtonWidth + 8;
/// Swipeable question card: right submits, left defers (does not resolve). /// Swipeable question card: right submits, left defers (does not resolve).
/// ///
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up). /// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
@ -30,14 +35,19 @@ class SwipeQuestionTile extends StatefulWidget {
} }
class _SwipeQuestionTileState extends State<SwipeQuestionTile> class _SwipeQuestionTileState extends State<SwipeQuestionTile>
with SingleTickerProviderStateMixin { with TickerProviderStateMixin {
double _dragOffset = 0; double _dragOffset = 0;
double _verticalOffset = 0; double _verticalOffset = 0;
bool _acting = false; bool _acting = false;
bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false;
int _lastSnappedValue = 0; int _lastSnappedValue = 0;
late final AnimationController _snapController; late final AnimationController _snapController;
late final AnimationController _holdSaveController;
late final AnimationController _holdDeferController;
static const double _swipeThreshold = 96; static const double _swipeThreshold = 96;
static const Duration _holdEdgeDuration = Duration(seconds: 1);
static const double _glyphSize = 80; static const double _glyphSize = 80;
static const double _trackEdgeInset = 8; static const double _trackEdgeInset = 8;
static const int _sliderMin = -10; static const int _sliderMin = -10;
@ -48,7 +58,20 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool get _atZero => _snappedSliderValue == 0; bool get _atZero => _snappedSliderValue == 0;
bool get _horizontalSwipeEnabled => !widget.busy && !_acting && !_atZero; bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
bool get _holdDeferEnabled => !widget.busy && !_acting;
bool get _holdSaveActive =>
_holdSavePointerDown || _holdSaveController.value > 0;
bool get _holdDeferActive =>
_holdDeferPointerDown || _holdDeferController.value > 0;
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
bool get _horizontalSwipeEnabled =>
_holdSaveEnabled && !_holdEdgeActive;
@override @override
void initState() { void initState() {
@ -57,14 +80,160 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 140), duration: const Duration(milliseconds: 140),
); );
_holdSaveController = AnimationController(
vsync: this,
duration: _holdEdgeDuration,
)..addListener(_syncHoldSaveDrag)
..addStatusListener(_onHoldSaveStatus);
_holdDeferController = AnimationController(
vsync: this,
duration: _holdEdgeDuration,
)..addListener(_syncHoldDeferDrag)
..addStatusListener(_onHoldDeferStatus);
} }
@override @override
void dispose() { void dispose() {
_snapController.dispose(); _snapController.dispose();
_holdSaveController.dispose();
_holdDeferController.dispose();
super.dispose(); super.dispose();
} }
void _syncHoldSaveDrag() {
if (!mounted || _acting || _holdDeferActive) {
return;
}
final double width = MediaQuery.sizeOf(context).width;
final double next = _holdSaveController.value * width;
if (next == _dragOffset) {
return;
}
setState(() => _dragOffset = next);
}
void _syncHoldDeferDrag() {
if (!mounted || _acting || _holdSaveActive) {
return;
}
final double width = MediaQuery.sizeOf(context).width;
final double next = -_holdDeferController.value * width;
if (next == _dragOffset) {
return;
}
setState(() => _dragOffset = next);
}
void _onHoldEdgeDismissed() {
if (mounted && !_acting) {
setState(() => _dragOffset = 0);
}
}
void _onHoldSaveStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
if (!_holdDeferActive) {
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdSavePointerDown &&
mounted) {
_holdSavePointerDown = false;
unawaited(_completeHoldSave());
}
}
void _onHoldDeferStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
if (!_holdSaveActive) {
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdDeferPointerDown &&
mounted) {
_holdDeferPointerDown = false;
unawaited(_completeHoldDefer());
}
}
void _onHoldSavePointerDown(PointerDownEvent event) {
if (!_holdSaveEnabled || _holdDeferActive) {
return;
}
_holdSavePointerDown = true;
_holdSaveController.forward(from: _holdSaveController.value);
}
void _onHoldSavePointerUp(PointerEvent event) {
if (!_holdSavePointerDown) {
return;
}
_holdSavePointerDown = false;
if (_holdSaveController.value >= 1.0) {
unawaited(_completeHoldSave());
return;
}
unawaited(_holdSaveController.reverse());
}
Future<void> _completeHoldSave() async {
if (_acting || widget.busy || _atZero || !mounted) {
return;
}
final double width = MediaQuery.sizeOf(context).width;
setState(() {
_acting = true;
_dragOffset = width;
});
await widget.onSwipeRight(_snappedSliderValue);
if (mounted) {
setState(() {
_acting = false;
_dragOffset = 0;
});
_holdSaveController.reset();
}
}
void _onHoldDeferPointerDown(PointerDownEvent event) {
if (!_holdDeferEnabled || _holdSaveActive) {
return;
}
_holdDeferPointerDown = true;
_holdDeferController.forward(from: _holdDeferController.value);
}
void _onHoldDeferPointerUp(PointerEvent event) {
if (!_holdDeferPointerDown) {
return;
}
_holdDeferPointerDown = false;
if (_holdDeferController.value >= 1.0) {
unawaited(_completeHoldDefer());
return;
}
unawaited(_holdDeferController.reverse());
}
Future<void> _completeHoldDefer() async {
if (_acting || widget.busy || !mounted) {
return;
}
final double width = MediaQuery.sizeOf(context).width;
setState(() {
_acting = true;
_dragOffset = -width;
});
await widget.onSwipeLeft();
if (mounted) {
setState(() {
_acting = false;
_dragOffset = 0;
});
_holdDeferController.reset();
}
}
/// Swipe up +10, swipe down -10; snaps to whole numbers. /// Swipe up +10, swipe down -10; snaps to whole numbers.
int get _snappedSliderValue => int get _snappedSliderValue =>
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax) (_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
@ -234,8 +403,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
Positioned( Positioned(
top: 8, top: 8,
bottom: 8, bottom: 8,
left: constraints.maxWidth * 0.22, left: _centerSideInset,
right: constraints.maxWidth * 0.22, right: _centerSideInset,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -248,8 +417,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
Positioned( Positioned(
top: 8, top: 8,
bottom: 8, bottom: 8,
left: constraints.maxWidth * 0.22, left: _centerSideInset,
right: constraints.maxWidth * 0.22, right: _centerSideInset,
child: Listener( child: Listener(
onPointerSignal: (PointerSignalEvent event) { onPointerSignal: (PointerSignalEvent event) {
if (widget.busy || _acting) { if (widget.busy || _acting) {
@ -283,6 +452,45 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
), ),
), ),
), ),
Positioned(
left: 0,
top: 10,
bottom: 10,
child: _HoldSwipeEdgeButton(
side: _HoldSwipeEdgeSide.left,
enabled: _holdDeferEnabled,
progress: _holdDeferController.value,
active: _holdDeferPointerDown,
icon: Icons.reply,
ringColor: AppColors.accent,
tooltipEnabled: 'Hold to defer question',
tooltipDisabled: 'Unavailable while busy',
semanticsLabel: 'Hold to defer question',
onPointerDown: _onHoldDeferPointerDown,
onPointerUp: _onHoldDeferPointerUp,
onPointerCancel: _onHoldDeferPointerUp,
),
),
Positioned(
right: 0,
top: 10,
bottom: 10,
child: _HoldSwipeEdgeButton(
side: _HoldSwipeEdgeSide.right,
enabled: _holdSaveEnabled,
progress: _holdSaveController.value,
active: _holdSavePointerDown,
icon: Icons.save,
ringColor: AppColors.success,
tooltipEnabled: 'Hold to save answer',
tooltipDisabled:
'Set an answer before saving',
semanticsLabel: 'Hold to save answer',
onPointerDown: _onHoldSavePointerDown,
onPointerUp: _onHoldSavePointerUp,
onPointerCancel: _onHoldSavePointerUp,
),
),
], ],
), ),
), ),
@ -307,3 +515,119 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
); );
} }
} }
enum _HoldSwipeEdgeSide { left, right }
/// Edge control: hold ~1s to sweep the tile off-screen.
class _HoldSwipeEdgeButton extends StatelessWidget {
const _HoldSwipeEdgeButton({
required this.side,
required this.enabled,
required this.progress,
required this.active,
required this.icon,
required this.ringColor,
required this.tooltipEnabled,
required this.tooltipDisabled,
required this.semanticsLabel,
required this.onPointerDown,
required this.onPointerUp,
required this.onPointerCancel,
});
final _HoldSwipeEdgeSide side;
final bool enabled;
final double progress;
final bool active;
final IconData icon;
final Color ringColor;
final String tooltipEnabled;
final String tooltipDisabled;
final String semanticsLabel;
final void Function(PointerDownEvent event) onPointerDown;
final void Function(PointerEvent event) onPointerUp;
final void Function(PointerEvent event) onPointerCancel;
@override
Widget build(BuildContext context) {
final Color iconColor = enabled
? AppColors.textPrimary.withValues(alpha: active ? 1 : 0.85)
: AppColors.textSecondary.withValues(alpha: 0.35);
final Color progressColor = enabled
? ringColor.withValues(alpha: active ? 0.95 : 0.7)
: AppColors.textSecondary.withValues(alpha: 0.2);
final bool onLeft = side == _HoldSwipeEdgeSide.left;
return Listener(
onPointerDown: enabled ? onPointerDown : null,
onPointerUp: enabled ? onPointerUp : null,
onPointerCancel: enabled ? onPointerCancel : null,
child: Tooltip(
message: enabled ? tooltipEnabled : tooltipDisabled,
child: Semantics(
button: true,
enabled: enabled,
label: semanticsLabel,
child: Container(
width: _holdEdgeButtonWidth,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surface.withValues(alpha: enabled ? 0.92 : 0.72),
borderRadius: BorderRadius.horizontal(
left: onLeft ? Radius.zero : const Radius.circular(12),
right: onLeft ? const Radius.circular(12) : Radius.zero,
),
border: Border(
left: onLeft
? BorderSide.none
: BorderSide(
color: AppColors.accent.withValues(alpha: 0.14),
),
right: onLeft
? BorderSide(
color: AppColors.accent.withValues(alpha: 0.14),
)
: BorderSide.none,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withValues(alpha: 0.32),
offset: Offset(onLeft ? 4 : -4, 0),
blurRadius: 12,
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.14),
offset: const Offset(0, 2),
blurRadius: 6,
),
],
),
child: Center(
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
if (progress > 0)
CircularProgressIndicator(
value: progress,
strokeWidth: 2.5,
backgroundColor: progressColor.withValues(alpha: 0.2),
color: progressColor,
),
Icon(
icon,
size: 20,
color: iconColor,
),
],
),
),
),
),
),
),
);
}
}

View File

@ -139,7 +139,18 @@ Handler questionsHandler({
nextQuestion = nextQuestion =
await questionService.ensureProspectiveQuestionQueued(firebaseUid); await questionService.ensureProspectiveQuestionQueued(firebaseUid);
if (nextQuestion != null) { if (nextQuestion != null) {
unansweredCount = 1; unansweredCount =
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
await questionsDb.countUnansweredQuestions(firebaseUid);
}
} else {
final Map<String, dynamic>? nextRow =
await questionsDb.findUnansweredQuestion(firebaseUid);
if (nextRow != null) {
nextQuestion = questionsDb.toClientPayload(
nextRow,
unansweredCount: unansweredCount,
);
} }
} }
final Map<String, dynamic> score = final Map<String, dynamic> score =
@ -174,9 +185,21 @@ Handler questionsHandler({
} }
final int unansweredCount = final int unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid); await questionsDb.countUnansweredQuestions(firebaseUid);
final Map<String, dynamic>? nextRow =
await questionsDb.findNextUnansweredAfterDefer(
assignedUserId: firebaseUid,
deferredQuestionId: id,
);
final Map<String, dynamic>? nextQuestion = nextRow == null
? null
: questionsDb.toClientPayload(
nextRow,
unansweredCount: unansweredCount,
);
return _jsonResponse(200, <String, dynamic>{ return _jsonResponse(200, <String, dynamic>{
'question': updated, 'question': updated,
'unansweredCount': unansweredCount, 'unansweredCount': unansweredCount,
if (nextQuestion != null) 'nextQuestion': nextQuestion,
}); });
} catch (e, st) { } catch (e, st) {
stderr.writeln('Defer question error: $e\n$st'); stderr.writeln('Defer question error: $e\n$st');

View File

@ -3,7 +3,9 @@ import 'dart:io';
import 'questions_db.dart'; import 'questions_db.dart';
import 'signalr/questions_hub_connections.dart'; import 'signalr/questions_hub_connections.dart';
import 'trading/prospective_guess_selection.dart';
import 'trading/prospective_guess_assignments_db.dart'; import 'trading/prospective_guess_assignments_db.dart';
import 'trading/market_history_question_audit.dart';
/// Creates questions in Postgres and delivers them over SignalR. /// Creates questions in Postgres and delivers them over SignalR.
class QuestionService { class QuestionService {
@ -27,29 +29,13 @@ class QuestionService {
return ensureProspectiveQuestionQueued(firebaseUid); return ensureProspectiveQuestionQueued(firebaseUid);
} }
/// Creates the next slot-based prospective question when the queue is empty. /// Ensures the active slot's top-half batch exists, then returns the FIFO head.
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued( Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
String firebaseUid, { String firebaseUid, {
DateTime? now, DateTime? now,
}) async { }) async {
await _questionsDb.ensureUserExists(firebaseUid); await _questionsDb.ensureUserExists(firebaseUid);
await _ensureCurrentSlotBatch(firebaseUid, now: now);
final String? pendingQuestionId =
await _assignmentsDb.findPendingQuestionId(firebaseUid);
if (pendingQuestionId != null) {
final Map<String, dynamic>? question = await _questionsDb.findQuestionById(
questionId: pendingQuestionId,
assignedUserId: firebaseUid,
);
if (question != null && question['userResponse'] == null) {
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(firebaseUid);
return _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount > 0 ? unansweredCount : 1,
);
}
}
final int queued = final int queued =
await _questionsDb.countUnansweredQuestions(firebaseUid); await _questionsDb.countUnansweredQuestions(firebaseUid);
@ -65,52 +51,45 @@ class QuestionService {
); );
} }
final Map<String, dynamic>? prospective =
await _createProspectiveQuestionWithAssignment(
firebaseUid,
now: now,
);
if (prospective == null) {
return null; return null;
} }
return prospective; /// Creates every top-half question for the user's active slot pair.
} Future<void> _ensureCurrentSlotBatch(
/// Picks, creates, and records one prospective question when none is queued.
///
/// Retries when a concurrent request claims the same user/slot/symbol first.
Future<Map<String, dynamic>?> _createProspectiveQuestionWithAssignment(
String firebaseUid, { String firebaseUid, {
DateTime? now, DateTime? now,
}) async { }) async {
for (var attempt = 0; attempt < 8; attempt++) { final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
final Map<String, dynamic>? picked = connection: _questionsDb.connection,
await _questionsDb.pickProspectiveQuestionForUser(
firebaseUid,
now: now,
); );
if (picked == null) {
return null; for (var attempt = 0; attempt < 8; attempt++) {
final ProspectiveSlotBatch? batch =
await selection.resolveUnassignedBatch(firebaseUid, now: now);
if (batch == null || batch.unassignedAssets.isEmpty) {
return;
} }
final DateTime olderSlotStart = DateTime.parse( final DateTime baseOrder = DateTime.now().toUtc();
picked['olderSlotStart']! as String, var createdAny = false;
);
final DateTime newerSlotStart = DateTime.parse(
picked['newerSlotStart']! as String,
);
final String symbol = picked['symbol']! as String;
for (var index = 0; index < batch.unassignedAssets.length; index++) {
final QuestionAuditAsset asset = batch.unassignedAssets[index];
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair( if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart, olderSlotStart: batch.olderSlotStart,
newerSlotStart: newerSlotStart, newerSlotStart: batch.newerSlotStart,
symbol: symbol, symbol: asset.symbol,
)) { )) {
continue; continue;
} }
final Map<String, dynamic> picked = await selection.buildPickForAsset(
asset: asset,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
);
final Map<String, dynamic> question = await _questionsDb.createQuestion( final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: firebaseUid, assignedUserId: firebaseUid,
questionText: picked['questionText']! as String, questionText: picked['questionText']! as String,
@ -120,7 +99,7 @@ class QuestionService {
pipelineStep: 'guess_weekly_move:await_answer', pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{ metadata: <String, dynamic>{
'prospective_question_id': picked['id'], 'prospective_question_id': picked['id'],
'symbol': symbol, 'symbol': asset.symbol,
'older_slot_start': picked['olderSlotStart'], 'older_slot_start': picked['olderSlotStart'],
'newer_slot_start': picked['newerSlotStart'], 'newer_slot_start': picked['newerSlotStart'],
'price_delta_pct': picked['priceDeltaPct'], 'price_delta_pct': picked['priceDeltaPct'],
@ -129,11 +108,12 @@ class QuestionService {
final bool assigned = await _assignmentsDb.insertPendingIfAbsent( final bool assigned = await _assignmentsDb.insertPendingIfAbsent(
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart, olderSlotStart: batch.olderSlotStart,
newerSlotStart: newerSlotStart, newerSlotStart: batch.newerSlotStart,
symbol: symbol, symbol: asset.symbol,
prospectiveQuestionId: picked['id']! as String, prospectiveQuestionId: picked['id']! as String,
questionId: question['id']! as String, questionId: question['id']! as String,
viewOrderAt: baseOrder.add(Duration(milliseconds: index)),
); );
if (!assigned) { if (!assigned) {
await _questionsDb.deleteUnansweredQuestion( await _questionsDb.deleteUnansweredQuestion(
@ -142,13 +122,13 @@ class QuestionService {
); );
continue; continue;
} }
createdAny = true;
return _questionsDb.toClientPayload( }
question,
unansweredCount: 1, if (createdAny) {
); return;
}
} }
return null;
} }
/// Inserts a question and pushes it to connected SignalR clients. /// Inserts a question and pushes it to connected SignalR clients.

View File

@ -33,6 +33,22 @@ class QuestionsDb {
); );
} }
static const String _questionsSelectColumns = '''
q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer,
q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step,
q.metadata''';
static const String _unansweredQuestionsJoin = '''
FROM questions q
LEFT JOIN market_history_prospective_assignments a
ON a.question_id = q.id
AND a.assigned_user_id = q.assigned_user_id
AND a.status = 'pending'
WHERE q.assigned_user_id = @uid AND q.user_response IS NULL''';
static const String _unansweredQuestionsOrder = '''
ORDER BY COALESCE(a.view_order_at, q.created_at) ASC, q.id ASC''';
/// Latest unanswered question for [assignedUserId], or null if none. /// Latest unanswered question for [assignedUserId], or null if none.
Future<Map<String, dynamic>?> findUnansweredQuestion( Future<Map<String, dynamic>?> findUnansweredQuestion(
String assignedUserId, String assignedUserId,
@ -40,12 +56,9 @@ class QuestionsDb {
final Result result = await _connection.execute( final Result result = await _connection.execute(
Sql.named( Sql.named(
''' '''
SELECT id, assigned_user_id, question_text, user_response, correct_answer, SELECT $_questionsSelectColumns
created_at, modified_at, source_tag, pipeline_key, pipeline_step, $_unansweredQuestionsJoin
metadata $_unansweredQuestionsOrder
FROM questions
WHERE assigned_user_id = @uid AND user_response IS NULL
ORDER BY created_at ASC
LIMIT 1 LIMIT 1
''', ''',
), ),
@ -64,12 +77,9 @@ class QuestionsDb {
final Result result = await _connection.execute( final Result result = await _connection.execute(
Sql.named( Sql.named(
''' '''
SELECT id, assigned_user_id, question_text, user_response, correct_answer, SELECT $_questionsSelectColumns
created_at, modified_at, source_tag, pipeline_key, pipeline_step, $_unansweredQuestionsJoin
metadata $_unansweredQuestionsOrder
FROM questions
WHERE assigned_user_id = @uid AND user_response IS NULL
ORDER BY created_at ASC
''', ''',
), ),
parameters: <String, dynamic>{'uid': assignedUserId}, parameters: <String, dynamic>{'uid': assignedUserId},
@ -77,6 +87,24 @@ class QuestionsDb {
return result.map(_rowFromResult).toList(); return result.map(_rowFromResult).toList();
} }
/// FIFO head after deferring [deferredQuestionId] (falls back to sole item).
Future<Map<String, dynamic>?> findNextUnansweredAfterDefer({
required String assignedUserId,
required String deferredQuestionId,
}) async {
final List<Map<String, dynamic>> rows =
await listUnansweredQuestions(assignedUserId);
if (rows.isEmpty) {
return null;
}
for (final Map<String, dynamic> row in rows) {
if (row['id'] != deferredQuestionId) {
return row;
}
}
return rows.first;
}
/// Removes an unanswered question owned by [assignedUserId]. /// Removes an unanswered question owned by [assignedUserId].
Future<void> deleteUnansweredQuestion({ Future<void> deleteUnansweredQuestion({
required String questionId, required String questionId,
@ -243,17 +271,27 @@ class QuestionsDb {
required String assignedUserId, required String assignedUserId,
}) async { }) async {
final DateTime now = DateTime.now().toUtc(); final DateTime now = DateTime.now().toUtc();
final ProspectiveGuessAssignmentsDb assignmentsDb =
ProspectiveGuessAssignmentsDb(_connection);
final bool pushed = await assignmentsDb.pushToBackOfQueue(
questionId: questionId,
firebaseUid: assignedUserId,
now: now,
);
if (!pushed) {
return _deferQuestionWithoutAssignment(
questionId: questionId,
assignedUserId: assignedUserId,
modifiedAt: now,
);
}
final Result result = await _connection.execute( final Result result = await _connection.execute(
Sql.named( Sql.named(
''' '''
UPDATE questions q UPDATE questions q
SET created_at = sub.max_ts + INTERVAL '1 millisecond', SET modified_at = @modified_at
modified_at = @modified_at
FROM (
SELECT COALESCE(MAX(created_at), @modified_at) AS max_ts
FROM questions
WHERE assigned_user_id = @uid AND user_response IS NULL
) sub
WHERE q.id = @id::uuid WHERE q.id = @id::uuid
AND q.assigned_user_id = @uid AND q.assigned_user_id = @uid
AND q.user_response IS NULL AND q.user_response IS NULL
@ -274,6 +312,45 @@ class QuestionsDb {
return _rowFromResult(result.first); return _rowFromResult(result.first);
} }
/// Legacy defer for questions without a pending assignment row.
Future<Map<String, dynamic>?> _deferQuestionWithoutAssignment({
required String questionId,
required String assignedUserId,
required DateTime modifiedAt,
}) async {
final Result result = await _connection.execute(
Sql.named(
'''
UPDATE questions q
SET created_at = sub.max_ts + INTERVAL '1 millisecond',
modified_at = @modified_at
FROM (
SELECT COALESCE(MAX(q.created_at), @modified_at) AS max_ts
FROM questions q
WHERE q.assigned_user_id = @uid
AND q.user_response IS NULL
AND q.id <> @id::uuid
) sub
WHERE q.id = @id::uuid
AND q.assigned_user_id = @uid
AND q.user_response IS NULL
RETURNING q.id, q.assigned_user_id, q.question_text, q.user_response,
q.correct_answer, q.created_at, q.modified_at,
q.source_tag, q.pipeline_key, q.pipeline_step, q.metadata
''',
),
parameters: <String, dynamic>{
'id': questionId,
'uid': assignedUserId,
'modified_at': modifiedAt,
},
);
if (result.isEmpty) {
return null;
}
return _rowFromResult(result.first);
}
/// Count of unanswered questions assigned to [assignedUserId]. /// Count of unanswered questions assigned to [assignedUserId].
Future<int> countUnansweredQuestions(String assignedUserId) async { Future<int> countUnansweredQuestions(String assignedUserId) async {
final Result result = await _connection.execute( final Result result = await _connection.execute(

View File

@ -22,7 +22,7 @@ class ProspectiveGuessAssignmentsDb {
WHERE a.assigned_user_id = @uid WHERE a.assigned_user_id = @uid
AND a.status = @pending AND a.status = @pending
AND q.user_response IS NULL AND q.user_response IS NULL
ORDER BY a.created_at ASC ORDER BY a.view_order_at ASC, a.question_id ASC
LIMIT 1 LIMIT 1
''', ''',
), ),
@ -94,6 +94,48 @@ class ProspectiveGuessAssignmentsDb {
return result.isNotEmpty; return result.isNotEmpty;
} }
/// Moves a pending assignment to the tail of [firebaseUid]'s FIFO queue.
///
/// Sets [view_order_at] to the current time (or later than any pending peer)
/// so the deferred question is served last on the next fetch.
Future<bool> pushToBackOfQueue({
required String questionId,
required String firebaseUid,
DateTime? now,
}) async {
final DateTime tick = (now ?? DateTime.now()).toUtc();
final Result result = await _connection.execute(
Sql.named(
'''
UPDATE market_history_prospective_assignments a
SET view_order_at = (
SELECT GREATEST(
@now,
COALESCE(MAX(a2.view_order_at), @now) + INTERVAL '1 millisecond'
)
FROM market_history_prospective_assignments a2
INNER JOIN questions q2 ON q2.id = a2.question_id
WHERE a2.assigned_user_id = @uid
AND a2.status = @pending
AND q2.user_response IS NULL
AND a2.question_id <> @question_id::uuid
)
WHERE a.question_id = @question_id::uuid
AND a.assigned_user_id = @uid
AND a.status = @pending
RETURNING a.id
''',
),
parameters: <String, dynamic>{
'question_id': questionId,
'uid': firebaseUid,
'pending': statusPending,
'now': tick,
},
);
return result.isNotEmpty;
}
Future<Set<String>> assignedSymbolsForSlotPair({ Future<Set<String>> assignedSymbolsForSlotPair({
required String firebaseUid, required String firebaseUid,
required DateTime olderSlotStart, required DateTime olderSlotStart,
@ -154,6 +196,7 @@ class ProspectiveGuessAssignmentsDb {
required String symbol, required String symbol,
required String prospectiveQuestionId, required String prospectiveQuestionId,
required String questionId, required String questionId,
DateTime? viewOrderAt,
}) async { }) async {
final Result result = await _connection.execute( final Result result = await _connection.execute(
Sql.named( Sql.named(
@ -165,7 +208,8 @@ class ProspectiveGuessAssignmentsDb {
symbol, symbol,
prospective_question_id, prospective_question_id,
question_id, question_id,
status status,
view_order_at
) VALUES ( ) VALUES (
@uid, @uid,
@older, @older,
@ -173,7 +217,8 @@ class ProspectiveGuessAssignmentsDb {
@symbol, @symbol,
@prospective_question_id::uuid, @prospective_question_id::uuid,
@question_id::uuid, @question_id::uuid,
@pending @pending,
COALESCE(@view_order_at, now())
) )
ON CONFLICT (assigned_user_id, older_slot_start, newer_slot_start, symbol) ON CONFLICT (assigned_user_id, older_slot_start, newer_slot_start, symbol)
DO NOTHING DO NOTHING
@ -188,6 +233,7 @@ class ProspectiveGuessAssignmentsDb {
'prospective_question_id': prospectiveQuestionId, 'prospective_question_id': prospectiveQuestionId,
'question_id': questionId, 'question_id': questionId,
'pending': statusPending, 'pending': statusPending,
'view_order_at': viewOrderAt?.toUtc(),
}, },
); );
return result.isNotEmpty; return result.isNotEmpty;

View File

@ -8,12 +8,24 @@ import 'market_history_session_slot.dart';
import 'prospective_guess_assignments_db.dart'; import 'prospective_guess_assignments_db.dart';
import 'user_trading_state_db.dart'; import 'user_trading_state_db.dart';
/// Unassigned top-half symbols for the user's active session-half slot pair.
class ProspectiveSlotBatch {
const ProspectiveSlotBatch({
required this.olderSlotStart,
required this.newerSlotStart,
required this.unassignedAssets,
});
final DateTime olderSlotStart;
final DateTime newerSlotStart;
final List<QuestionAuditAsset> unassignedAssets;
}
/// Picks prospective guess questions by session-half slot progression. /// Picks prospective guess questions by session-half slot progression.
/// ///
/// User state stores the older slot edge in `guess_score.slot_start`. Each slot /// User state stores the older slot edge in `guess_score.slot_start`. Each slot
/// pair gets one question per top-half volume asset /// pair gets the full top-half volume batch at once (one question per symbol in
/// ([market_history_prospective_assignments] enforces uniqueness per symbol). /// that batch). [slot_start] advances after every top-half symbol is answered.
/// [slot_start] advances after every top-half symbol in the pair is answered.
class ProspectiveGuessSelection { class ProspectiveGuessSelection {
ProspectiveGuessSelection({ ProspectiveGuessSelection({
required Connection connection, required Connection connection,
@ -41,13 +53,33 @@ class ProspectiveGuessSelection {
); );
} }
/// Next symbol for [firebaseUid] when no assignment exists for the pair. /// Next symbol for [firebaseUid] when batching one question at a time.
/// ///
/// Returns null when caught up, when a pending assignment already exists, or /// Returns null when caught up or when the current slot pair already has
/// when every top-half symbol in the current pair has been answered. /// pending assignments.
Future<Map<String, dynamic>?> pickForUser( Future<Map<String, dynamic>?> pickForUser(
String firebaseUid, { String firebaseUid, {
DateTime? now, DateTime? now,
}) async {
final ProspectiveSlotBatch? batch =
await resolveUnassignedBatch(firebaseUid, now: now);
if (batch == null || batch.unassignedAssets.isEmpty) {
return null;
}
return buildPickForAsset(
asset: batch.unassignedAssets.first,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
);
}
/// All top-half symbols in the active slot pair that still need a question.
///
/// Advances [slot_start] past completed pairs. Returns null when caught up or
/// when every top-half symbol in the active pair is already assigned.
Future<ProspectiveSlotBatch?> resolveUnassignedBatch(
String firebaseUid, {
DateTime? now,
}) async { }) async {
final DateTime tick = (now ?? DateTime.now()).toUtc(); final DateTime tick = (now ?? DateTime.now()).toUtc();
final DateTime lastCompleted = final DateTime lastCompleted =
@ -65,14 +97,6 @@ class ProspectiveGuessSelection {
return null; return null;
} }
if (await _assignmentsDb.hasPendingAssignmentForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
)) {
return null;
}
final List<QuestionAuditAsset> topHalf = final List<QuestionAuditAsset> topHalf =
await _topHalfAssetsForSlotPair( await _topHalfAssetsForSlotPair(
olderSlotStart: olderSlot, olderSlotStart: olderSlot,
@ -98,28 +122,24 @@ class ProspectiveGuessSelection {
continue; continue;
} }
final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair( final List<QuestionAuditAsset> unassigned =
await _unassignedAssetsForSlotPair(
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
olderSlotStart: olderSlot, olderSlotStart: olderSlot,
newerSlotStart: newerSlot, newerSlotStart: newerSlot,
topHalf: topHalf, topHalf: topHalf,
); );
if (asset != null) { if (unassigned.isNotEmpty) {
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
final String id = await _upsertProspectiveRow( return ProspectiveSlotBatch(
asset: asset,
olderSlotStart: olderSlot, olderSlotStart: olderSlot,
newerSlotStart: newerSlot, newerSlotStart: newerSlot,
unassignedAssets: unassigned,
); );
return <String, dynamic>{ }
'id': id,
'questionText': _questionText(asset.symbol), if (!topSymbols.every(answeredSymbols.contains)) {
'correctAnswer': asset.priceDelta, return null;
'symbol': asset.symbol,
'olderSlotStart': olderSlot.toIso8601String(),
'newerSlotStart': newerSlot.toIso8601String(),
'priceDeltaPct': asset.priceDelta,
};
} }
olderSlot = newerSlot; olderSlot = newerSlot;
@ -127,6 +147,52 @@ class ProspectiveGuessSelection {
} }
return null; return null;
} }
Future<Map<String, dynamic>> buildPickForAsset({
required QuestionAuditAsset asset,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final DateTime older =
MarketHistorySessionSlot.slotStartContaining(olderSlotStart.toUtc());
final DateTime newer =
MarketHistorySessionSlot.slotStartContaining(newerSlotStart.toUtc());
final String id = await _upsertProspectiveRow(
asset: asset,
olderSlotStart: older,
newerSlotStart: newer,
);
return <String, dynamic>{
'id': id,
'questionText': _questionText(asset.symbol),
'correctAnswer': asset.priceDelta,
'symbol': asset.symbol,
'olderSlotStart': older.toIso8601String(),
'newerSlotStart': newer.toIso8601String(),
'priceDeltaPct': asset.priceDelta,
};
}
Future<List<QuestionAuditAsset>> _unassignedAssetsForSlotPair({
required String firebaseUid,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
required List<QuestionAuditAsset> topHalf,
}) async {
final List<QuestionAuditAsset> unassigned = <QuestionAuditAsset>[];
for (final QuestionAuditAsset asset in topHalf) {
final bool alreadyAssigned =
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: asset.symbol,
);
if (!alreadyAssigned) {
unassigned.add(asset);
}
}
return unassigned;
}
Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({ Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
required DateTime olderSlotStart, required DateTime olderSlotStart,
@ -140,28 +206,6 @@ class ProspectiveGuessSelection {
return questionAuditTopHalfVolumeAssets(assets); return questionAuditTopHalfVolumeAssets(assets);
} }
/// Highest-volume symbol in [topHalf] the user has not yet been assigned.
Future<QuestionAuditAsset?> _pickNextAssetForSlotPair({
required String firebaseUid,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
required List<QuestionAuditAsset> topHalf,
}) async {
for (final QuestionAuditAsset asset in topHalf) {
final bool alreadyAssigned =
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: asset.symbol,
);
if (!alreadyAssigned) {
return asset;
}
}
return null;
}
Future<String> _upsertProspectiveRow({ Future<String> _upsertProspectiveRow({
required QuestionAuditAsset asset, required QuestionAuditAsset asset,
required DateTime olderSlotStart, required DateTime olderSlotStart,

View File

@ -0,0 +1,16 @@
-- Per-user FIFO queue order for pending guess assignments (defer pushes to back).
ALTER TABLE market_history_prospective_assignments
ADD COLUMN IF NOT EXISTS view_order_at TIMESTAMPTZ;
UPDATE market_history_prospective_assignments
SET view_order_at = created_at
WHERE view_order_at IS NULL;
ALTER TABLE market_history_prospective_assignments
ALTER COLUMN view_order_at SET NOT NULL,
ALTER COLUMN view_order_at SET DEFAULT now();
CREATE INDEX IF NOT EXISTS market_history_prospective_assignments_user_view_order_idx
ON market_history_prospective_assignments (assigned_user_id, view_order_at ASC)
WHERE status = 'pending';

View File

@ -11,7 +11,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart';
import 'package:dotenv/dotenv.dart'; import 'package:dotenv/dotenv.dart';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001016. /// Integration test Postgres: [cyberhybridhub_test] with migrations 001017.
class TestDb { class TestDb {
TestDb._(this.db, this._connection, this.databaseUrl); TestDb._(this.db, this._connection, this.databaseUrl);

View File

@ -576,6 +576,7 @@ void main() {
final Map<String, dynamic>? firstPayload = final Map<String, dynamic>? firstPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now); await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(firstPayload, isNotNull); expect(firstPayload, isNotNull);
expect(firstPayload!['unansweredCount'], 2);
final Result assignmentRows = await testDb!.connection.execute( final Result assignmentRows = await testDb!.connection.execute(
Sql.named( Sql.named(
@ -588,9 +589,10 @@ void main() {
), ),
parameters: <String, dynamic>{'uid': uid}, parameters: <String, dynamic>{'uid': uid},
); );
expect(assignmentRows, hasLength(1)); expect(assignmentRows, hasLength(2));
expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending); expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending);
expect(assignmentRows.first[0], 'HIGH'); expect(assignmentRows.first[0], 'HIGH');
expect(assignmentRows.elementAt(1)[0], 'MID');
expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot); expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot);
expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot); expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot);
@ -599,21 +601,23 @@ void main() {
final Map<String, dynamic>? reloginPayload = final Map<String, dynamic>? reloginPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now); await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(reloginPayload, isNotNull); expect(reloginPayload, isNotNull);
expect(reloginPayload!['id'], firstPayload!['id']); expect(reloginPayload!['id'], firstPayload['id']);
expect(reloginPayload['unansweredCount'], 2);
final List<Map<String, dynamic>> queued = final List<Map<String, dynamic>> queued =
await testDb!.questionsDb.listUnansweredQuestions(uid); await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(queued, hasLength(1)); expect(queued, hasLength(2));
await testDb!.questionsDb.submitAnswer( await testDb!.questionsDb.submitAnswer(
questionId: queued.single['id']! as String, questionId: queued.first['id']! as String,
assignedUserId: uid, assignedUserId: uid,
userResponse: 0, userResponse: 0,
); );
final Map<String, dynamic>? secondPayload = final Map<String, dynamic>? afterFirstAnswer =
await service.ensureProspectiveQuestionQueued(uid, now: now); await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(secondPayload, isNotNull); expect(afterFirstAnswer, isNotNull);
expect(secondPayload!['id'], isNot(firstPayload['id'])); expect(afterFirstAnswer!['unansweredCount'], 1);
expect(afterFirstAnswer['id'], isNot(firstPayload['id']));
final Result secondPairAssignments = await testDb!.connection.execute( final Result secondPairAssignments = await testDb!.connection.execute(
Sql.named( Sql.named(
@ -629,13 +633,9 @@ void main() {
expect(secondPairAssignments, hasLength(2)); expect(secondPairAssignments, hasLength(2));
expect(secondPairAssignments.first[0], 'HIGH'); expect(secondPairAssignments.first[0], 'HIGH');
expect(secondPairAssignments.last[0], 'MID'); expect(secondPairAssignments.last[0], 'MID');
expect(secondPairAssignments.last[1],
ProspectiveGuessAssignmentsDb.statusPending);
expect((secondPairAssignments.last[2]! as DateTime).toUtc(), olderSlot);
expect((secondPairAssignments.last[3]! as DateTime).toUtc(), newerSlot);
await testDb!.questionsDb.submitAnswer( await testDb!.questionsDb.submitAnswer(
questionId: secondPayload['id']! as String, questionId: afterFirstAnswer['id']! as String,
assignedUserId: uid, assignedUserId: uid,
userResponse: 0, userResponse: 0,
); );
@ -643,8 +643,8 @@ void main() {
final Map<String, dynamic>? thirdPayload = final Map<String, dynamic>? thirdPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now); await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(thirdPayload, isNotNull); expect(thirdPayload, isNotNull);
expect(thirdPayload!['id'], isNot(firstPayload['id'])); expect(thirdPayload!['unansweredCount'], 1);
expect(thirdPayload['id'], isNot(secondPayload['id'])); expect(thirdPayload['id'], isNot(firstPayload['id']));
final Result allAssignments = await testDb!.connection.execute( final Result allAssignments = await testDb!.connection.execute(
Sql.named( Sql.named(
@ -658,9 +658,7 @@ void main() {
parameters: <String, dynamic>{'uid': uid}, parameters: <String, dynamic>{'uid': uid},
); );
expect(allAssignments, hasLength(3)); expect(allAssignments, hasLength(3));
expect(allAssignments.last[0], 'HIGH'); expect(allAssignments.elementAt(2)[0], 'HIGH');
expect((allAssignments.last[1]! as DateTime).toUtc(), newerSlot);
expect((allAssignments.last[2]! as DateTime).toUtc(), nextOlderSlot);
}); });
test('blocks duplicate assignment for same user symbol and slot pair', () async { test('blocks duplicate assignment for same user symbol and slot pair', () async {
@ -875,4 +873,291 @@ void main() {
expect(metadata['newer_slot_start'], newerSlot.toIso8601String()); expect(metadata['newer_slot_start'], newerSlot.toIso8601String());
expect(created['text'], contains('BOOT')); expect(created['text'], contains('BOOT'));
}); });
test('deferQuestion pushes assignment view_order to back of FIFO queue',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'fifo-defer-uid';
final DateTime now = DateTime.utc(2026, 5, 10, 16);
final DateTime newerSlot =
MarketHistorySessionSlot.lastCompletedSlotStart(now);
final DateTime olderSlot =
MarketHistorySessionSlot.previousSlotStart(newerSlot)!;
await testDb!.seedUser(uid);
Future<String> insertProspective(String symbol) async {
final Result rows = await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot
) VALUES (
@compare_until, @newer, @older,
@symbol, @text, 1.0,
1.0, 0.0, 1000,
'{}'::jsonb, '{}'::jsonb
)
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer': newerSlot,
'older': olderSlot,
'symbol': symbol,
'text': 'Move for $symbol?',
},
);
return rows.first[0].toString();
}
final String prospectiveHigh = await insertProspective('HIGH');
final String prospectiveMid = await insertProspective('MID');
final Map<String, dynamic> firstQuestion =
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess HIGH',
correctAnswer: 1,
sourceTag: 'market_history:prospective',
metadata: <String, dynamic>{'prospective_question_id': prospectiveHigh},
);
final Map<String, dynamic> secondQuestion =
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess MID',
correctAnswer: 1,
sourceTag: 'market_history:prospective',
metadata: <String, dynamic>{'prospective_question_id': prospectiveMid},
);
final ProspectiveGuessAssignmentsDb assignmentsDb =
ProspectiveGuessAssignmentsDb(testDb!.connection);
await assignmentsDb.insertPending(
firebaseUid: uid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
symbol: 'HIGH',
prospectiveQuestionId: prospectiveHigh,
questionId: firstQuestion['id']! as String,
);
await assignmentsDb.insertPending(
firebaseUid: uid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
symbol: 'MID',
prospectiveQuestionId: prospectiveMid,
questionId: secondQuestion['id']! as String,
);
final DateTime firstOrder = DateTime.utc(2026, 5, 10, 10);
final DateTime secondOrder = DateTime.utc(2026, 5, 10, 11);
await testDb!.connection.execute(
Sql.named(
'''
UPDATE market_history_prospective_assignments
SET view_order_at = @view_order_at
WHERE question_id = @question_id::uuid
''',
),
parameters: <String, dynamic>{
'view_order_at': firstOrder,
'question_id': firstQuestion['id']! as String,
},
);
await testDb!.connection.execute(
Sql.named(
'''
UPDATE market_history_prospective_assignments
SET view_order_at = @view_order_at
WHERE question_id = @question_id::uuid
''',
),
parameters: <String, dynamic>{
'view_order_at': secondOrder,
'question_id': secondQuestion['id']! as String,
},
);
final Map<String, dynamic>? headBefore =
await testDb!.questionsDb.findUnansweredQuestion(uid);
expect(headBefore!['id'], firstQuestion['id']);
final DateTime deferStarted = DateTime.now().toUtc();
final Map<String, dynamic>? deferred = await testDb!.questionsDb.deferQuestion(
questionId: firstQuestion['id']! as String,
assignedUserId: uid,
);
expect(deferred, isNotNull);
expect(deferred!['createdAt'], firstQuestion['createdAt']);
final Result deferredOrder = await testDb!.connection.execute(
Sql.named(
'''
SELECT view_order_at
FROM market_history_prospective_assignments
WHERE question_id = @question_id::uuid
''',
),
parameters: <String, dynamic>{
'question_id': firstQuestion['id']! as String,
},
);
final DateTime deferredViewOrder =
(deferredOrder.first[0]! as DateTime).toUtc();
expect(
deferredViewOrder.isAfter(secondOrder) ||
deferredViewOrder.isAtSameMomentAs(secondOrder),
isTrue,
);
expect(deferredViewOrder.isBefore(deferStarted), isFalse);
final Map<String, dynamic>? headAfter =
await testDb!.questionsDb.findUnansweredQuestion(uid);
expect(headAfter!['id'], secondQuestion['id']);
final List<Map<String, dynamic>> queue =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(queue, hasLength(2));
expect(queue.first['id'], secondQuestion['id']);
expect(queue.last['id'], firstQuestion['id']);
final String? pendingHead =
await assignmentsDb.findPendingQuestionId(uid);
expect(pendingHead, secondQuestion['id']);
});
test(
'deferQuestion moves head to back when it had the latest view_order_at',
() async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
const String uid = 'fifo-defer-head-latest-uid';
final DateTime olderSlot = DateTime.utc(2026, 5, 10, 10);
final DateTime newerSlot = DateTime.utc(2026, 5, 10, 14);
await testDb!.seedUser(uid);
Future<({String prospectiveId, Map<String, dynamic> question})>
seedAssigned(
String symbol,
String text,
) async {
final Result rows = await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot
) VALUES (
@compare_until, @newer, @older,
@symbol, @text, 1.0,
1.0, 0.0, 1000,
'{}'::jsonb, '{}'::jsonb
)
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer': newerSlot,
'older': olderSlot,
'symbol': symbol,
'text': text,
},
);
final String prospectiveId = rows.first[0].toString();
final Map<String, dynamic> question =
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: text,
correctAnswer: 1,
sourceTag: 'market_history:prospective',
metadata: <String, dynamic>{
'prospective_question_id': prospectiveId,
},
);
await ProspectiveGuessAssignmentsDb(testDb!.connection).insertPending(
firebaseUid: uid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
symbol: symbol,
prospectiveQuestionId: prospectiveId,
questionId: question['id']! as String,
);
return (prospectiveId: prospectiveId, question: question);
}
final ({String prospectiveId, Map<String, dynamic> question}) first =
await seedAssigned('HEAD', 'Head question');
final ({String prospectiveId, Map<String, dynamic> question}) second =
await seedAssigned('TAIL', 'Tail question');
final DateTime tailOrder = DateTime.utc(2026, 5, 10, 9);
final DateTime headOrder = DateTime.utc(2026, 5, 10, 12);
await testDb!.connection.execute(
Sql.named(
'''
UPDATE market_history_prospective_assignments
SET view_order_at = @tail_order
WHERE assigned_user_id = @uid
AND question_id = @tail_id::uuid
''',
),
parameters: <String, dynamic>{
'uid': uid,
'tail_id': second.question['id']! as String,
'tail_order': tailOrder,
},
);
await testDb!.connection.execute(
Sql.named(
'''
UPDATE market_history_prospective_assignments
SET view_order_at = @head_order
WHERE assigned_user_id = @uid
AND question_id = @head_id::uuid
''',
),
parameters: <String, dynamic>{
'uid': uid,
'head_id': first.question['id']! as String,
'head_order': headOrder,
},
);
final Map<String, dynamic>? headBefore =
await testDb!.questionsDb.findUnansweredQuestion(uid);
expect(headBefore!['id'], second.question['id']);
await testDb!.questionsDb.deferQuestion(
questionId: second.question['id']! as String,
assignedUserId: uid,
);
final Map<String, dynamic>? headAfter =
await testDb!.questionsDb.findUnansweredQuestion(uid);
expect(headAfter!['id'], first.question['id']);
final List<Map<String, dynamic>> queue =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(queue.first['id'], first.question['id']);
expect(queue.last['id'], second.question['id']);
},
);
} }