Question FIFO
This commit is contained in:
parent
609317bc35
commit
0c4c72f9c9
@ -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<Offset> unitVertices() {
|
||||
final int n = vertexCount;
|
||||
final List<Offset> points = <Offset>[];
|
||||
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<List<int>> _icosahedronFaces = <List<int>>[
|
||||
<int>[0, 11, 5],
|
||||
<int>[0, 5, 1],
|
||||
<int>[0, 1, 7],
|
||||
<int>[0, 7, 10],
|
||||
<int>[0, 10, 11],
|
||||
<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 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<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.
|
||||
@ -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<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).
|
||||
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<Offset> 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<Offset> 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<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
|
||||
|
||||
12
lib/models/question_defer_result.dart
Normal file
12
lib/models/question_defer_result.dart
Normal 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;
|
||||
}
|
||||
@ -279,7 +279,7 @@ class _QuestionEnvelopeButton extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
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,
|
||||
|
||||
@ -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<int?> deferQuestion({required String questionId}) async {
|
||||
Future<QuestionDeferResult?> 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<String, dynamic> body =
|
||||
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 {
|
||||
|
||||
@ -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<IncomingQuestion> queue = List<IncomingQuestion>.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<IncomingQuestion> 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<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 {
|
||||
questionActionBusy.value = false;
|
||||
}
|
||||
@ -283,17 +308,6 @@ class QuestionsHubService {
|
||||
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.
|
||||
void _clearPendingUi() {
|
||||
hasPendingQuestion.value = false;
|
||||
|
||||
@ -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<SwipeQuestionTile>
|
||||
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<SwipeQuestionTile>
|
||||
|
||||
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<SwipeQuestionTile>
|
||||
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<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.
|
||||
int get _snappedSliderValue =>
|
||||
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
|
||||
@ -234,8 +403,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
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<SwipeQuestionTile>
|
||||
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<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, dynamic>? nextRow =
|
||||
await questionsDb.findUnansweredQuestion(firebaseUid);
|
||||
if (nextRow != null) {
|
||||
nextQuestion = questionsDb.toClientPayload(
|
||||
nextRow,
|
||||
unansweredCount: unansweredCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
final Map<String, dynamic> score =
|
||||
@ -174,9 +185,21 @@ Handler questionsHandler({
|
||||
}
|
||||
final int unansweredCount =
|
||||
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>{
|
||||
'question': updated,
|
||||
'unansweredCount': unansweredCount,
|
||||
if (nextQuestion != null) 'nextQuestion': nextQuestion,
|
||||
});
|
||||
} catch (e, st) {
|
||||
stderr.writeln('Defer question error: $e\n$st');
|
||||
|
||||
@ -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<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
|
||||
String firebaseUid, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
await _questionsDb.ensureUserExists(firebaseUid);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
await _ensureCurrentSlotBatch(firebaseUid, now: now);
|
||||
|
||||
final int queued =
|
||||
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||
@ -65,90 +51,84 @@ class QuestionService {
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, dynamic>? 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<Map<String, dynamic>?> _createProspectiveQuestionWithAssignment(
|
||||
/// Creates every top-half question for the user's active slot pair.
|
||||
Future<void> _ensureCurrentSlotBatch(
|
||||
String firebaseUid, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
|
||||
connection: _questionsDb.connection,
|
||||
);
|
||||
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
final Map<String, dynamic>? 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<String, dynamic> 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: <String, dynamic>{
|
||||
'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<String, dynamic> picked = await selection.buildPickForAsset(
|
||||
asset: asset,
|
||||
olderSlotStart: batch.olderSlotStart,
|
||||
newerSlotStart: batch.newerSlotStart,
|
||||
);
|
||||
continue;
|
||||
|
||||
final Map<String, dynamic> 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: <String, dynamic>{
|
||||
'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.
|
||||
|
||||
@ -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<Map<String, dynamic>?> 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: <String, dynamic>{'uid': assignedUserId},
|
||||
@ -77,6 +87,24 @@ class QuestionsDb {
|
||||
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].
|
||||
Future<void> 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<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].
|
||||
Future<int> countUnansweredQuestions(String assignedUserId) async {
|
||||
final Result result = await _connection.execute(
|
||||
|
||||
@ -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<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({
|
||||
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;
|
||||
|
||||
@ -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<QuestionAuditAsset> 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<Map<String, dynamic>?> 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<ProspectiveSlotBatch?> 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<QuestionAuditAsset> topHalf =
|
||||
await _topHalfAssetsForSlotPair(
|
||||
olderSlotStart: olderSlot,
|
||||
@ -98,28 +122,24 @@ class ProspectiveGuessSelection {
|
||||
continue;
|
||||
}
|
||||
|
||||
final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair(
|
||||
final List<QuestionAuditAsset> 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 <String, dynamic>{
|
||||
'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<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({
|
||||
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<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({
|
||||
required QuestionAuditAsset asset,
|
||||
required DateTime olderSlotStart,
|
||||
|
||||
16
server/migrations/017_prospective_assignments_view_order.sql
Normal file
16
server/migrations/017_prospective_assignments_view_order.sql
Normal 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';
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -576,6 +576,7 @@ void main() {
|
||||
final Map<String, dynamic>? 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: <String, dynamic>{'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<String, dynamic>? 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<Map<String, dynamic>> 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<String, dynamic>? secondPayload =
|
||||
final Map<String, dynamic>? 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<String, dynamic>? 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: <String, dynamic>{'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<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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user