diff --git a/lib/guid/guid_glyph_shape.dart b/lib/guid/guid_glyph_shape.dart index 3295eb2..9b7d45e 100644 --- a/lib/guid/guid_glyph_shape.dart +++ b/lib/guid/guid_glyph_shape.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.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 -/// polar vertices so the same id always produces the same glyph. +/// Parses the canonical 16-byte UUID (hyphens optional), builds a perturbed +/// icosahedron with optional stellated facets, and paints it with depth-sorted +/// shaded faces so the same id always produces the same glyph. class GuidGlyphShape { GuidGlyphShape._(this.bytes); @@ -23,25 +24,155 @@ class GuidGlyphShape { return GuidGlyphShape._(_hashTo16Bytes(guid)); } - /// Vertex count in [5, 10], from the first UUID byte. - int get vertexCount => 5 + (bytes[0] % 6); + static const double _phi = 1.618033988749895; - /// Unit-circle offsets (rough polygon); multiply by size when painting. - List unitVertices() { - final int n = vertexCount; - final List points = []; - double angle = (bytes[1] / 255) * math.pi * 2; + static final List<_Vec3> _icosahedronBase = <_Vec3>[ + _Vec3(-1, _phi, 0), + _Vec3(1, _phi, 0), + _Vec3(-1, -_phi, 0), + _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++) { - final int rIndex = (i * 2) % 16; - final int aIndex = (i * 2 + 1) % 16; - final double radius = 0.42 + (bytes[rIndex] / 255) * 0.52; - angle += (math.pi * 2 / n) + ((bytes[aIndex] / 255) - 0.5) * 0.95; - points.add( - Offset(math.cos(angle) * radius, math.sin(angle) * radius), + static const List> _icosahedronFaces = >[ + [0, 11, 5], + [0, 5, 1], + [0, 1, 7], + [0, 7, 10], + [0, 10, 11], + [1, 5, 9], + [5, 11, 4], + [11, 10, 2], + [10, 7, 6], + [7, 1, 8], + [3, 9, 4], + [3, 4, 2], + [3, 2, 6], + [3, 6, 8], + [3, 8, 9], + [4, 9, 5], + [2, 4, 11], + [6, 2, 10], + [8, 6, 7], + [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> 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 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 points; + + 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, + ); + } + + List 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. @@ -50,41 +181,13 @@ class GuidGlyphShape { 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]. static double displayT(num displayValue) => (displayValue / 10).clamp(-1.0, 1.0).toDouble(); - /// Display-only vertex warp; same [guid] + [displayValue] always matches. - List displayUnitVertices(num displayValue) { - final double t = displayT(displayValue); - final List 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 out = []; - 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). static bool isNeutralZero(num displayValue) => displayValue == 0; @@ -116,7 +219,22 @@ class GuidGlyphShape { 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 maxR = 0; for (final Offset p in displayUnitVertices(displayValue)) { @@ -135,7 +253,7 @@ class GuidGlyphShape { double displayStrokeWidth(num 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) { @@ -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). class QuestionGuidGlyph extends StatelessWidget { const QuestionGuidGlyph({ @@ -188,10 +376,8 @@ class QuestionGuidGlyph extends StatelessWidget { final Color glow = shape.displayGlowColor(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue); 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.1); + final double paintedRadius = baseRadius * (1 + intensity * 0.12); - // Shadow box matches painted extent; blur/spread grow with that diameter. final double bodyDiameter = paintedRadius * 2; final double blur = bodyDiameter * (0.2 + intensity * 0.16); 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 scale = paintedRadius / maxUnitR; final double strokeWidth = shape.displayStrokeWidth(displayValue); - final List unit = shape.displayUnitVertices(displayValue); - final Path path = Path(); - for (int i = 0; i < unit.length; i++) { - final Offset p = center + Offset(unit[i].dx * scale, unit[i].dy * scale); - if (i == 0) { - path.moveTo(p.dx, p.dy); - } else { - path.lineTo(p.dx, p.dy); - } + final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh = + shape.displayMesh(displayValue); + final List projected = mesh.vertices + .map((v) => center + shape._projectUnit(v) * scale) + .toList(growable: false); + + final List<_MeshFace> sorted = List<_MeshFace>.from(mesh.faces) + ..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( + path, + Paint() + ..color = shape._shadeFaceColor(baseFill, shade, displayValue) + ..style = PaintingStyle.fill, + ); } - path.close(); - final Color fill = shape.displayFillColor(displayValue); - final Color stroke = shape.displayStrokeColor(displayValue); + final Color edgeColor = shape.displayStrokeColor(displayValue); + final Paint edgePaint = Paint() + ..color = edgeColor.withValues(alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.55 : 0.75) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeJoin = StrokeJoin.round; - canvas.drawPath( - path, - Paint() - ..color = fill - ..style = PaintingStyle.fill, - ); - canvas.drawPath( - path, - Paint() - ..color = stroke - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth - ..strokeJoin = StrokeJoin.round, - ); + final Set drawnEdges = {}; + 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 projected, + Paint paint, + Set 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 diff --git a/lib/models/question_defer_result.dart b/lib/models/question_defer_result.dart new file mode 100644 index 0000000..32f13a8 --- /dev/null +++ b/lib/models/question_defer_result.dart @@ -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; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0cb4d6d..6fcab76 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -279,7 +279,7 @@ class _QuestionEnvelopeButton extends StatelessWidget { children: [ IconButton( onPressed: onPressed, - tooltip: count > 1 ? '$count questions' : 'New question', + tooltip: count == 1 ? '1 question' : '$count questions', icon: const Icon(Icons.mail_outline, size: 22), style: IconButton.styleFrom( backgroundColor: AppColors.accent.withValues(alpha: 0.15), @@ -289,7 +289,7 @@ class _QuestionEnvelopeButton extends StatelessWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), - if (count >= 2) + if (count >= 1) Positioned( top: 4, right: 0, diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index b219989..2b270f4 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import '../config/api_config.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../models/question_defer_result.dart'; import '../models/question_submit_result.dart'; import 'auth_service.dart'; @@ -120,7 +121,7 @@ class QuestionsApiService { ); } - Future deferQuestion({required String questionId}) async { + Future deferQuestion({required String questionId}) async { final String? token = await AuthService.instance.getIdToken(); if (token == null) { return null; @@ -139,7 +140,14 @@ class QuestionsApiService { final Map body = jsonDecode(response.body) as Map; - return (body['unansweredCount'] as num?)?.toInt(); + final Map? nextQuestionJson = + body['nextQuestion'] as Map?; + return QuestionDeferResult( + unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0, + nextQuestion: nextQuestionJson == null + ? null + : IncomingQuestion.fromJson(nextQuestionJson), + ); } Future fetchGuessScoreSummary() async { diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index 0d7fd29..cd83438 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -6,6 +6,7 @@ import 'package:signalr_netcore/signalr_client.dart'; import '../config/api_config.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../models/question_defer_result.dart'; import '../models/question_submit_result.dart'; import 'auth_service.dart'; import 'questions_api_service.dart'; @@ -173,26 +174,50 @@ class QuestionsHubService { return; } + final String skippedQuestionId = question.id; questionActionBusy.value = true; try { - final List queue = List.from( - questionQueue.value, + final QuestionDeferResult? result = await _api.deferQuestion( + questionId: skippedQuestionId, ); - if (queue.isEmpty) { + if (result == null) { return; } - final IncomingQuestion current = queue.removeAt(0); - queue.add(current); - final int? serverCount = await _api.deferQuestion(questionId: current.id); - if (serverCount != null) { - final List refreshed = await _api.fetchUnanswered(); - questionQueue.value = refreshed.isNotEmpty ? refreshed : queue; - _syncPendingFromQueue(serverCount); - } else { - questionQueue.value = queue; - _syncPendingFromQueue(queue.length); + if (result.unansweredCount == 0) { + _clearPendingUi(); + return; } + + final List 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 { questionActionBusy.value = false; } @@ -283,17 +308,6 @@ class QuestionsHubService { return true; } - void _syncPendingFromQueue(int count) { - final List 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. void _clearPendingUi() { hasPendingQuestion.value = false; diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index 1cec265..6b63e83 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -8,6 +8,11 @@ import 'package:flutter/services.dart'; import '../guid/guid_glyph_shape.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). /// /// 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 - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { double _dragOffset = 0; double _verticalOffset = 0; bool _acting = false; + bool _holdSavePointerDown = false; + bool _holdDeferPointerDown = false; int _lastSnappedValue = 0; late final AnimationController _snapController; + late final AnimationController _holdSaveController; + late final AnimationController _holdDeferController; static const double _swipeThreshold = 96; + static const Duration _holdEdgeDuration = Duration(seconds: 1); static const double _glyphSize = 80; static const double _trackEdgeInset = 8; static const int _sliderMin = -10; @@ -48,7 +58,20 @@ class _SwipeQuestionTileState extends State 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 void initState() { @@ -57,14 +80,160 @@ class _SwipeQuestionTileState extends State vsync: this, 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 void dispose() { _snapController.dispose(); + _holdSaveController.dispose(); + _holdDeferController.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 _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 _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. int get _snappedSliderValue => (_clampedVerticalOffset / _maxVerticalDrag * _sliderMax) @@ -234,8 +403,8 @@ class _SwipeQuestionTileState extends State Positioned( top: 8, bottom: 8, - left: constraints.maxWidth * 0.22, - right: constraints.maxWidth * 0.22, + left: _centerSideInset, + right: _centerSideInset, child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), @@ -248,8 +417,8 @@ class _SwipeQuestionTileState extends State Positioned( top: 8, bottom: 8, - left: constraints.maxWidth * 0.22, - right: constraints.maxWidth * 0.22, + left: _centerSideInset, + right: _centerSideInset, child: Listener( onPointerSignal: (PointerSignalEvent event) { if (widget.busy || _acting) { @@ -283,6 +452,45 @@ class _SwipeQuestionTileState extends State ), ), ), + 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 ); } } + +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( + 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: [ + if (progress > 0) + CircularProgressIndicator( + value: progress, + strokeWidth: 2.5, + backgroundColor: progressColor.withValues(alpha: 0.2), + color: progressColor, + ), + Icon( + icon, + size: 20, + color: iconColor, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index 21b9dcb..4a3bc59 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -139,7 +139,18 @@ Handler questionsHandler({ nextQuestion = await questionService.ensureProspectiveQuestionQueued(firebaseUid); if (nextQuestion != null) { - unansweredCount = 1; + unansweredCount = + (nextQuestion['unansweredCount'] as num?)?.toInt() ?? + await questionsDb.countUnansweredQuestions(firebaseUid); + } + } else { + final Map? nextRow = + await questionsDb.findUnansweredQuestion(firebaseUid); + if (nextRow != null) { + nextQuestion = questionsDb.toClientPayload( + nextRow, + unansweredCount: unansweredCount, + ); } } final Map score = @@ -174,9 +185,21 @@ Handler questionsHandler({ } final int unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); + final Map? nextRow = + await questionsDb.findNextUnansweredAfterDefer( + assignedUserId: firebaseUid, + deferredQuestionId: id, + ); + final Map? nextQuestion = nextRow == null + ? null + : questionsDb.toClientPayload( + nextRow, + unansweredCount: unansweredCount, + ); return _jsonResponse(200, { 'question': updated, 'unansweredCount': unansweredCount, + if (nextQuestion != null) 'nextQuestion': nextQuestion, }); } catch (e, st) { stderr.writeln('Defer question error: $e\n$st'); diff --git a/server/lib/question_service.dart b/server/lib/question_service.dart index aa38d1b..d7cd6e9 100644 --- a/server/lib/question_service.dart +++ b/server/lib/question_service.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'questions_db.dart'; import 'signalr/questions_hub_connections.dart'; +import 'trading/prospective_guess_selection.dart'; import 'trading/prospective_guess_assignments_db.dart'; +import 'trading/market_history_question_audit.dart'; /// Creates questions in Postgres and delivers them over SignalR. class QuestionService { @@ -27,29 +29,13 @@ class QuestionService { 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?> ensureProspectiveQuestionQueued( String firebaseUid, { DateTime? now, }) async { await _questionsDb.ensureUserExists(firebaseUid); - - final String? pendingQuestionId = - await _assignmentsDb.findPendingQuestionId(firebaseUid); - if (pendingQuestionId != null) { - final Map? 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, - ); - } - } + await _ensureCurrentSlotBatch(firebaseUid, now: now); final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid); @@ -65,90 +51,84 @@ class QuestionService { ); } - final Map? prospective = - await _createProspectiveQuestionWithAssignment( - firebaseUid, - now: now, - ); - if (prospective == null) { - return null; - } - - return prospective; + return null; } - /// Picks, creates, and records one prospective question when none is queued. - /// - /// Retries when a concurrent request claims the same user/slot/symbol first. - Future?> _createProspectiveQuestionWithAssignment( + /// Creates every top-half question for the user's active slot pair. + Future _ensureCurrentSlotBatch( String firebaseUid, { DateTime? now, }) async { + final ProspectiveGuessSelection selection = ProspectiveGuessSelection( + connection: _questionsDb.connection, + ); + for (var attempt = 0; attempt < 8; attempt++) { - final Map? picked = - await _questionsDb.pickProspectiveQuestionForUser( - firebaseUid, - now: now, - ); - if (picked == null) { - return null; + final ProspectiveSlotBatch? batch = + await selection.resolveUnassignedBatch(firebaseUid, now: now); + if (batch == null || batch.unassignedAssets.isEmpty) { + return; } - final DateTime olderSlotStart = DateTime.parse( - picked['olderSlotStart']! as String, - ); - final DateTime newerSlotStart = DateTime.parse( - picked['newerSlotStart']! as String, - ); - final String symbol = picked['symbol']! as String; + final DateTime baseOrder = DateTime.now().toUtc(); + var createdAny = false; - if (await _assignmentsDb.hasAssignmentForSymbolSlotPair( - firebaseUid: firebaseUid, - olderSlotStart: olderSlotStart, - newerSlotStart: newerSlotStart, - symbol: symbol, - )) { - continue; - } + for (var index = 0; index < batch.unassignedAssets.length; index++) { + final QuestionAuditAsset asset = batch.unassignedAssets[index]; + if (await _assignmentsDb.hasAssignmentForSymbolSlotPair( + firebaseUid: firebaseUid, + olderSlotStart: batch.olderSlotStart, + newerSlotStart: batch.newerSlotStart, + symbol: asset.symbol, + )) { + continue; + } - final Map question = await _questionsDb.createQuestion( - assignedUserId: firebaseUid, - questionText: picked['questionText']! as String, - correctAnswer: picked['correctAnswer']! as num, - sourceTag: 'market_history:prospective', - pipelineKey: 'trading', - pipelineStep: 'guess_weekly_move:await_answer', - metadata: { - 'prospective_question_id': picked['id'], - 'symbol': symbol, - 'older_slot_start': picked['olderSlotStart'], - 'newer_slot_start': picked['newerSlotStart'], - 'price_delta_pct': picked['priceDeltaPct'], - }, - ); - - final bool assigned = await _assignmentsDb.insertPendingIfAbsent( - firebaseUid: firebaseUid, - olderSlotStart: olderSlotStart, - newerSlotStart: newerSlotStart, - symbol: symbol, - prospectiveQuestionId: picked['id']! as String, - questionId: question['id']! as String, - ); - if (!assigned) { - await _questionsDb.deleteUnansweredQuestion( - questionId: question['id']! as String, - assignedUserId: firebaseUid, + final Map picked = await selection.buildPickForAsset( + asset: asset, + olderSlotStart: batch.olderSlotStart, + newerSlotStart: batch.newerSlotStart, ); - continue; + + final Map question = await _questionsDb.createQuestion( + assignedUserId: firebaseUid, + questionText: picked['questionText']! as String, + correctAnswer: picked['correctAnswer']! as num, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': picked['id'], + 'symbol': asset.symbol, + 'older_slot_start': picked['olderSlotStart'], + 'newer_slot_start': picked['newerSlotStart'], + 'price_delta_pct': picked['priceDeltaPct'], + }, + ); + + final bool assigned = await _assignmentsDb.insertPendingIfAbsent( + firebaseUid: firebaseUid, + olderSlotStart: batch.olderSlotStart, + newerSlotStart: batch.newerSlotStart, + symbol: asset.symbol, + prospectiveQuestionId: picked['id']! as String, + questionId: question['id']! as String, + viewOrderAt: baseOrder.add(Duration(milliseconds: index)), + ); + if (!assigned) { + await _questionsDb.deleteUnansweredQuestion( + questionId: question['id']! as String, + assignedUserId: firebaseUid, + ); + continue; + } + createdAny = true; } - return _questionsDb.toClientPayload( - question, - unansweredCount: 1, - ); + if (createdAny) { + return; + } } - return null; } /// Inserts a question and pushes it to connected SignalR clients. diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index f9942d2..5024939 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -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. Future?> findUnansweredQuestion( String assignedUserId, @@ -40,12 +56,9 @@ class QuestionsDb { final Result result = await _connection.execute( Sql.named( ''' - SELECT id, assigned_user_id, question_text, user_response, correct_answer, - created_at, modified_at, source_tag, pipeline_key, pipeline_step, - metadata - FROM questions - WHERE assigned_user_id = @uid AND user_response IS NULL - ORDER BY created_at ASC + SELECT $_questionsSelectColumns + $_unansweredQuestionsJoin + $_unansweredQuestionsOrder LIMIT 1 ''', ), @@ -64,12 +77,9 @@ class QuestionsDb { final Result result = await _connection.execute( Sql.named( ''' - SELECT id, assigned_user_id, question_text, user_response, correct_answer, - created_at, modified_at, source_tag, pipeline_key, pipeline_step, - metadata - FROM questions - WHERE assigned_user_id = @uid AND user_response IS NULL - ORDER BY created_at ASC + SELECT $_questionsSelectColumns + $_unansweredQuestionsJoin + $_unansweredQuestionsOrder ''', ), parameters: {'uid': assignedUserId}, @@ -77,6 +87,24 @@ class QuestionsDb { return result.map(_rowFromResult).toList(); } + /// FIFO head after deferring [deferredQuestionId] (falls back to sole item). + Future?> findNextUnansweredAfterDefer({ + required String assignedUserId, + required String deferredQuestionId, + }) async { + final List> rows = + await listUnansweredQuestions(assignedUserId); + if (rows.isEmpty) { + return null; + } + for (final Map row in rows) { + if (row['id'] != deferredQuestionId) { + return row; + } + } + return rows.first; + } + /// Removes an unanswered question owned by [assignedUserId]. Future deleteUnansweredQuestion({ required String questionId, @@ -243,17 +271,27 @@ class QuestionsDb { required String assignedUserId, }) async { 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( Sql.named( ''' UPDATE questions q - SET created_at = sub.max_ts + INTERVAL '1 millisecond', - 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 + SET modified_at = @modified_at WHERE q.id = @id::uuid AND q.assigned_user_id = @uid AND q.user_response IS NULL @@ -274,6 +312,45 @@ class QuestionsDb { return _rowFromResult(result.first); } + /// Legacy defer for questions without a pending assignment row. + Future?> _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: { + 'id': questionId, + 'uid': assignedUserId, + 'modified_at': modifiedAt, + }, + ); + if (result.isEmpty) { + return null; + } + return _rowFromResult(result.first); + } + /// Count of unanswered questions assigned to [assignedUserId]. Future countUnansweredQuestions(String assignedUserId) async { final Result result = await _connection.execute( diff --git a/server/lib/trading/prospective_guess_assignments_db.dart b/server/lib/trading/prospective_guess_assignments_db.dart index b2f8b20..4af2b35 100644 --- a/server/lib/trading/prospective_guess_assignments_db.dart +++ b/server/lib/trading/prospective_guess_assignments_db.dart @@ -22,7 +22,7 @@ class ProspectiveGuessAssignmentsDb { WHERE a.assigned_user_id = @uid AND a.status = @pending 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 ''', ), @@ -94,6 +94,48 @@ class ProspectiveGuessAssignmentsDb { 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 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: { + 'question_id': questionId, + 'uid': firebaseUid, + 'pending': statusPending, + 'now': tick, + }, + ); + return result.isNotEmpty; + } + Future> assignedSymbolsForSlotPair({ required String firebaseUid, required DateTime olderSlotStart, @@ -154,6 +196,7 @@ class ProspectiveGuessAssignmentsDb { required String symbol, required String prospectiveQuestionId, required String questionId, + DateTime? viewOrderAt, }) async { final Result result = await _connection.execute( Sql.named( @@ -165,7 +208,8 @@ class ProspectiveGuessAssignmentsDb { symbol, prospective_question_id, question_id, - status + status, + view_order_at ) VALUES ( @uid, @older, @@ -173,7 +217,8 @@ class ProspectiveGuessAssignmentsDb { @symbol, @prospective_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) DO NOTHING @@ -188,6 +233,7 @@ class ProspectiveGuessAssignmentsDb { 'prospective_question_id': prospectiveQuestionId, 'question_id': questionId, 'pending': statusPending, + 'view_order_at': viewOrderAt?.toUtc(), }, ); return result.isNotEmpty; diff --git a/server/lib/trading/prospective_guess_selection.dart b/server/lib/trading/prospective_guess_selection.dart index 1c0af2c..6bce8e9 100644 --- a/server/lib/trading/prospective_guess_selection.dart +++ b/server/lib/trading/prospective_guess_selection.dart @@ -8,12 +8,24 @@ import 'market_history_session_slot.dart'; import 'prospective_guess_assignments_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 unassignedAssets; +} + /// Picks prospective guess questions by session-half slot progression. /// /// User state stores the older slot edge in `guess_score.slot_start`. Each slot -/// pair gets one question per top-half volume asset -/// ([market_history_prospective_assignments] enforces uniqueness per symbol). -/// [slot_start] advances after every top-half symbol in the pair is answered. +/// pair gets the full top-half volume batch at once (one question per symbol in +/// that batch). [slot_start] advances after every top-half symbol is answered. class ProspectiveGuessSelection { ProspectiveGuessSelection({ 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 - /// when every top-half symbol in the current pair has been answered. + /// Returns null when caught up or when the current slot pair already has + /// pending assignments. Future?> pickForUser( String firebaseUid, { 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 resolveUnassignedBatch( + String firebaseUid, { + DateTime? now, }) async { final DateTime tick = (now ?? DateTime.now()).toUtc(); final DateTime lastCompleted = @@ -65,14 +97,6 @@ class ProspectiveGuessSelection { return null; } - if (await _assignmentsDb.hasPendingAssignmentForSlotPair( - firebaseUid: firebaseUid, - olderSlotStart: olderSlot, - newerSlotStart: newerSlot, - )) { - return null; - } - final List topHalf = await _topHalfAssetsForSlotPair( olderSlotStart: olderSlot, @@ -98,28 +122,24 @@ class ProspectiveGuessSelection { continue; } - final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair( + final List unassigned = + await _unassignedAssetsForSlotPair( firebaseUid: firebaseUid, olderSlotStart: olderSlot, newerSlotStart: newerSlot, topHalf: topHalf, ); - if (asset != null) { + if (unassigned.isNotEmpty) { await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); - final String id = await _upsertProspectiveRow( - asset: asset, + return ProspectiveSlotBatch( olderSlotStart: olderSlot, newerSlotStart: newerSlot, + unassignedAssets: unassigned, ); - return { - 'id': id, - 'questionText': _questionText(asset.symbol), - 'correctAnswer': asset.priceDelta, - 'symbol': asset.symbol, - 'olderSlotStart': olderSlot.toIso8601String(), - 'newerSlotStart': newerSlot.toIso8601String(), - 'priceDeltaPct': asset.priceDelta, - }; + } + + if (!topSymbols.every(answeredSymbols.contains)) { + return null; } olderSlot = newerSlot; @@ -127,6 +147,52 @@ class ProspectiveGuessSelection { } return null; } + Future> 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 { + 'id': id, + 'questionText': _questionText(asset.symbol), + 'correctAnswer': asset.priceDelta, + 'symbol': asset.symbol, + 'olderSlotStart': older.toIso8601String(), + 'newerSlotStart': newer.toIso8601String(), + 'priceDeltaPct': asset.priceDelta, + }; + } + + Future> _unassignedAssetsForSlotPair({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + required List topHalf, + }) async { + final List unassigned = []; + 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> _topHalfAssetsForSlotPair({ required DateTime olderSlotStart, @@ -140,28 +206,6 @@ class ProspectiveGuessSelection { return questionAuditTopHalfVolumeAssets(assets); } - /// Highest-volume symbol in [topHalf] the user has not yet been assigned. - Future _pickNextAssetForSlotPair({ - required String firebaseUid, - required DateTime olderSlotStart, - required DateTime newerSlotStart, - required List 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 _upsertProspectiveRow({ required QuestionAuditAsset asset, required DateTime olderSlotStart, diff --git a/server/migrations/017_prospective_assignments_view_order.sql b/server/migrations/017_prospective_assignments_view_order.sql new file mode 100644 index 0000000..72b8396 --- /dev/null +++ b/server/migrations/017_prospective_assignments_view_order.sql @@ -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'; diff --git a/server/test/helpers/test_db.dart b/server/test/helpers/test_db.dart index 11cce15..5ff1412 100644 --- a/server/test/helpers/test_db.dart +++ b/server/test/helpers/test_db.dart @@ -11,7 +11,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; import 'package:dotenv/dotenv.dart'; import 'package:postgres/postgres.dart'; -/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–016. +/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–017. class TestDb { TestDb._(this.db, this._connection, this.databaseUrl); diff --git a/server/test/integration/market_history_prospective_questions_test.dart b/server/test/integration/market_history_prospective_questions_test.dart index 89aeca1..2c6a8b2 100644 --- a/server/test/integration/market_history_prospective_questions_test.dart +++ b/server/test/integration/market_history_prospective_questions_test.dart @@ -576,6 +576,7 @@ void main() { final Map? firstPayload = await service.ensureProspectiveQuestionQueued(uid, now: now); expect(firstPayload, isNotNull); + expect(firstPayload!['unansweredCount'], 2); final Result assignmentRows = await testDb!.connection.execute( Sql.named( @@ -588,9 +589,10 @@ void main() { ), parameters: {'uid': uid}, ); - expect(assignmentRows, hasLength(1)); + expect(assignmentRows, hasLength(2)); expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending); expect(assignmentRows.first[0], 'HIGH'); + expect(assignmentRows.elementAt(1)[0], 'MID'); expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot); expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot); @@ -599,21 +601,23 @@ void main() { final Map? reloginPayload = await service.ensureProspectiveQuestionQueued(uid, now: now); expect(reloginPayload, isNotNull); - expect(reloginPayload!['id'], firstPayload!['id']); + expect(reloginPayload!['id'], firstPayload['id']); + expect(reloginPayload['unansweredCount'], 2); final List> queued = await testDb!.questionsDb.listUnansweredQuestions(uid); - expect(queued, hasLength(1)); + expect(queued, hasLength(2)); await testDb!.questionsDb.submitAnswer( - questionId: queued.single['id']! as String, + questionId: queued.first['id']! as String, assignedUserId: uid, userResponse: 0, ); - final Map? secondPayload = + final Map? afterFirstAnswer = await service.ensureProspectiveQuestionQueued(uid, now: now); - expect(secondPayload, isNotNull); - expect(secondPayload!['id'], isNot(firstPayload['id'])); + expect(afterFirstAnswer, isNotNull); + expect(afterFirstAnswer!['unansweredCount'], 1); + expect(afterFirstAnswer['id'], isNot(firstPayload['id'])); final Result secondPairAssignments = await testDb!.connection.execute( Sql.named( @@ -629,13 +633,9 @@ void main() { expect(secondPairAssignments, hasLength(2)); expect(secondPairAssignments.first[0], 'HIGH'); 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( - questionId: secondPayload['id']! as String, + questionId: afterFirstAnswer['id']! as String, assignedUserId: uid, userResponse: 0, ); @@ -643,8 +643,8 @@ void main() { final Map? thirdPayload = await service.ensureProspectiveQuestionQueued(uid, now: now); expect(thirdPayload, isNotNull); - expect(thirdPayload!['id'], isNot(firstPayload['id'])); - expect(thirdPayload['id'], isNot(secondPayload['id'])); + expect(thirdPayload!['unansweredCount'], 1); + expect(thirdPayload['id'], isNot(firstPayload['id'])); final Result allAssignments = await testDb!.connection.execute( Sql.named( @@ -658,9 +658,7 @@ void main() { parameters: {'uid': uid}, ); expect(allAssignments, hasLength(3)); - expect(allAssignments.last[0], 'HIGH'); - expect((allAssignments.last[1]! as DateTime).toUtc(), newerSlot); - expect((allAssignments.last[2]! as DateTime).toUtc(), nextOlderSlot); + expect(allAssignments.elementAt(2)[0], 'HIGH'); }); 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(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 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: { + '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 firstQuestion = + await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess HIGH', + correctAnswer: 1, + sourceTag: 'market_history:prospective', + metadata: {'prospective_question_id': prospectiveHigh}, + ); + final Map secondQuestion = + await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess MID', + correctAnswer: 1, + sourceTag: 'market_history:prospective', + metadata: {'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: { + '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: { + 'view_order_at': secondOrder, + 'question_id': secondQuestion['id']! as String, + }, + ); + + final Map? headBefore = + await testDb!.questionsDb.findUnansweredQuestion(uid); + expect(headBefore!['id'], firstQuestion['id']); + + final DateTime deferStarted = DateTime.now().toUtc(); + final Map? 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: { + '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? headAfter = + await testDb!.questionsDb.findUnansweredQuestion(uid); + expect(headAfter!['id'], secondQuestion['id']); + + final List> 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 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: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer': newerSlot, + 'older': olderSlot, + 'symbol': symbol, + 'text': text, + }, + ); + final String prospectiveId = rows.first[0].toString(); + final Map question = + await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: text, + correctAnswer: 1, + sourceTag: 'market_history:prospective', + metadata: { + '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 question}) first = + await seedAssigned('HEAD', 'Head question'); + final ({String prospectiveId, Map 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: { + '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: { + 'uid': uid, + 'head_id': first.question['id']! as String, + 'head_order': headOrder, + }, + ); + + final Map? 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? headAfter = + await testDb!.questionsDb.findUnansweredQuestion(uid); + expect(headAfter!['id'], first.question['id']); + + final List> queue = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(queue.first['id'], first.question['id']); + expect(queue.last['id'], second.question['id']); + }, + ); }