Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41a8aa994 | ||
|
|
02344eb3f5 | ||
|
|
e191aa5ad2 | ||
|
|
c544bc305a | ||
|
|
835a4663f7 | ||
|
|
995347d255 | ||
|
|
9159755846 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
tmp
|
||||
data
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
coverage
|
||||
.nyc_output
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Dockerfile
|
||||
|
||||
# Stage 1: Build the React application
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
LABEL stage="build-local-testing"
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
not sure good way to do this
|
||||
|
||||
|
||||
### npm install warnings cleanup pass
|
||||
lots of updates
|
||||
|
||||
|
||||
|
||||
### TEST GAP: current branch changes need focused coverage
|
||||
@@ -65,25 +68,6 @@ not sure good way to do this
|
||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||
display-only? skip in turn order?). Clarify intent before UX changes.
|
||||
|
||||
### FEAT: first-class undo/redo UI buttons (B --- do now)
|
||||
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
|
||||
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
|
||||
- Uses current 2-write undo (non-tx). Race safety = log refactor later.
|
||||
- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z).
|
||||
|
||||
### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched)
|
||||
- Single event schema, one source of truth:
|
||||
`{ ts, type, payload, undo_payload, undone, encounterId,
|
||||
snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }`
|
||||
- Common format consumed by: UI log view, download/copy export,
|
||||
replay-combat, analyze-turns. One shape, four consumers.
|
||||
- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single
|
||||
SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write
|
||||
(log update + encounter update as separate calls).
|
||||
- Download/copy: exports event stream as JSON for offline analysis.
|
||||
- replay-combat + analyze-turns rewritten to emit/consume same event shape.
|
||||
- Migration: keep old log entries readable; new format for new writes.
|
||||
|
||||
|
||||
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
|
||||
|
||||
@@ -95,133 +79,3 @@ not sure good way to do this
|
||||
## FEAT - clarify what end encounter does and what initiatives reset means
|
||||
|
||||
## Done (history)
|
||||
|
||||
### FEAT: first-class undo/redo UI buttons (DONE)
|
||||
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
|
||||
- Undo = latest non-undone log (per encounter). Redo = latest undone.
|
||||
- encounterPath added to all 14 log contexts (filter key).
|
||||
- redo:patch (forward) added to undoData. Real redo replays forward state.
|
||||
- Disabled when stack empty. Tooltip shows target action.
|
||||
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
|
||||
|
||||
### Architecture: 1-list turn order model (slot, never sort)
|
||||
- Single source: turnOrderIds === participants.map(id). No re-sort after
|
||||
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
|
||||
- Drag (reorder) = same-init tie-break only. Cross-init blocked.
|
||||
- startEncounter sorts ALL participants by init once, then frozen.
|
||||
- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
|
||||
drag order. Display renders participants[] directly (no sort).
|
||||
- Static guard test errs if `.sort(` reintroduced outside allowlist.
|
||||
- Design doc: docs/INITIATIVE_ORDERING.md.
|
||||
|
||||
### Single source of truth: combat logic
|
||||
- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
|
||||
- shared/turn.js = only place turn logic lives.
|
||||
|
||||
### Storage parity (firebase + server adapters)
|
||||
- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
|
||||
- Shared contract test runs both identically. Memory adapter deleted; factory
|
||||
throws on unknown mode.
|
||||
- ws storage mode renamed to server (env var + adapter name).
|
||||
|
||||
### Logging contract
|
||||
- Every mutating op logs message + undo payload. No-op = null log.
|
||||
- Structural enforcement: per-op contract test (turn.logging.test.js) +
|
||||
static source-scan guard (static.no-unlogged.test.js).
|
||||
|
||||
### Custom conditions per campaign (DONE)
|
||||
- Freeform per-campaign conditions. Add applies to participant + persists to
|
||||
campaign palette in one step. Badge render uses merged allConditions
|
||||
(built-ins + custom). toggleCondition accepts any string. Dedup
|
||||
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
|
||||
string ids survive round-trip.
|
||||
|
||||
### Death saves: D&D 5e status model (DONE)
|
||||
- `status` is source of truth: conscious, dying, stable, dead.
|
||||
- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
|
||||
- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
|
||||
- Revive: dead → 0 HP, stable/unconscious, active.
|
||||
- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive.
|
||||
- Player display hides inactive participants; DM display keeps them visible.
|
||||
|
||||
### FEAT-3: initiative first-class entry (DONE)
|
||||
- Initiative field at add-char, add-monster, edit participant.
|
||||
- Inline edit wired. Tie-break = drag order.
|
||||
|
||||
### UI feedback: toast + info modal (DONE)
|
||||
- All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X)
|
||||
for transient failures. InfoModal (persistent OK) for validations.
|
||||
React context provider wraps all 3 App branches.
|
||||
- Fixed: native alert vanished instantly on browser focus loss.
|
||||
|
||||
### Filter dup chars from add-participant dropdown (DONE)
|
||||
- Character dropdown excludes chars already in encounter. Prevents
|
||||
dup-add at source. No more dup alert path needed.
|
||||
|
||||
### Test timeouts (DONE)
|
||||
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
|
||||
|
||||
### Warning = failure in tests (DONE)
|
||||
- console.error/warn throw in test env.
|
||||
|
||||
### BUG-1: addParticipant + pause/resume corrupts rotation
|
||||
- RESOLVED as side effect of BUG-2 fix.
|
||||
|
||||
### BUG-2: addParticipant allows duplicate id
|
||||
- FIXED (addParticipant throws on dup id).
|
||||
|
||||
### BUG-4: hide-player-HP breaks display view
|
||||
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
|
||||
|
||||
### BUG-5: mid-round addParticipant/revive corrupts rotation
|
||||
- FIXED --- slot-array + DRY advance core nextActiveAfter.
|
||||
|
||||
### BUG-6: reorderParticipants doesn't update turnOrderIds
|
||||
- FIXED structurally by 1-list model.
|
||||
|
||||
### BUG-7: reorderParticipants not logged
|
||||
- FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave,
|
||||
addParticipants, updateParticipant logging gaps also closed.
|
||||
|
||||
### BUG-8: server adapter has no reconnect
|
||||
- FIXED --- onclose reconnects + re-subscribes existing paths.
|
||||
|
||||
### BUG-10: deact+reactivate same round double-acts participant
|
||||
- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not
|
||||
grant second turn. Test: turn.bug10.test.js.
|
||||
|
||||
### BUG-11: FE Combat.scenario test crashes
|
||||
- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds.
|
||||
|
||||
### BUG-12: campaign selection follows activeDisplay
|
||||
- FIXED.
|
||||
|
||||
### BUG-13: reorderParticipants crossing current pointer = ambiguous
|
||||
- FIXED --- block cross-pointer reorder during active encounter (both dirs).
|
||||
Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
|
||||
Pre-combat: free reorder. Test: turn.bug13.test.js.
|
||||
|
||||
### BUG-14: addParticipant init-insertion breaks after drag-reorder
|
||||
- FIXED --- slotIndexForInit scans current list (post-drag aware).
|
||||
|
||||
### BUG-15: DisplayView re-sorts (drag order not preserved)
|
||||
- FIXED --- display renders participants[] directly.
|
||||
|
||||
### BUG-16: subscribeCollection hook drops queryConstraints
|
||||
- FIXED --- neutral builders, both adapters honor orderBy/limit.
|
||||
|
||||
### BUG-17: dead SDK imports in App.js
|
||||
- FIXED --- trimmed (auth + getFirestore + getStorage remain).
|
||||
|
||||
### BUG-18: stale comments reference deleted memory adapter
|
||||
- FIXED.
|
||||
|
||||
### FEAT-1: Dead characters/NPCs stay in turn order
|
||||
- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
|
||||
- Non-NPC monsters still become inactive automatically at death.
|
||||
|
||||
### combat.scenario 100 rounds not turns
|
||||
- DONE --- loops by actual round-wrap count.
|
||||
|
||||
### feat: add all characters to participants list
|
||||
- DONE --- addParticipants bulk add wired.
|
||||
|
||||
+12
-11
@@ -1,34 +1,36 @@
|
||||
# docker/Dockerfile — single container: caddy (front) + node (back).
|
||||
# syntax=docker/dockerfile:1
|
||||
# docker/Dockerfile --- single container: caddy (front) + node (back).
|
||||
# Build context = repo root.
|
||||
# ---- build stage: frontend + install backend deps ----
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm install --include-workspace-root
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --include-workspace-root --omit=dev --prefer-offline
|
||||
|
||||
# frontend code (changes often)
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
COPY tailwind.config.js postcss.config.js ./
|
||||
|
||||
# better-sqlite3 native build (alpine musl)
|
||||
RUN cd server && npm rebuild better-sqlite3
|
||||
|
||||
# build frontend (server storage, same-origin /api + /ws via caddy)
|
||||
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
||||
ENV REACT_APP_STORAGE=server
|
||||
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||
|
||||
# prune backend dev deps for runtime
|
||||
RUN npm prune --omit=dev
|
||||
# server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
|
||||
COPY server/ ./server/
|
||||
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
|
||||
RUN cd server && npm rebuild better-sqlite3
|
||||
|
||||
|
||||
# ---- runtime stage: caddy + node ----
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache caddy
|
||||
|
||||
WORKDIR /app
|
||||
@@ -38,7 +40,6 @@ COPY --from=build /app/server/node_modules ./server/node_modules
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/shared/package.json ./shared/
|
||||
COPY --from=build /app/server/package.json ./server/
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
# built frontend served by caddy
|
||||
COPY --from=build /app/build /srv
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/docker-restart.sh — rebuild + restart docker container.
|
||||
# Usage: ./scripts/docker-restart.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== stopping existing container ==="
|
||||
docker compose -f docker/docker-compose.yml down 2>/dev/null || true
|
||||
|
||||
echo "=== rebuilding image ==="
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
echo "=== starting container ==="
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
echo "=== status ==="
|
||||
docker compose -f docker/docker-compose.yml ps
|
||||
|
||||
echo ""
|
||||
echo "app: http://localhost:${PORT:-8080}"
|
||||
echo "logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||
Generated
+7
-21
@@ -7151,14 +7151,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bfj": {
|
||||
@@ -21316,23 +21319,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -23021,7 +23007,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ttrpg/shared": "*",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"nanoid": "^5.0.7",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||
// Adapter (src/storage/server.js) = passthrough, no shape translation.
|
||||
// TEST: cache layer rebuild check.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ttrpg/shared": "*",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"nanoid": "^5.0.7",
|
||||
|
||||
+1
-1
@@ -2597,7 +2597,7 @@ function PlayerParticipantCard({ id, isActive, status, onExit, className, childr
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className={`transition-all duration-1000 ease-in-out ${fadeClass} ${deathClass} ${className}`}
|
||||
className={`[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${fadeClass} ${deathClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
{isDead && (
|
||||
|
||||
@@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
return unsub;
|
||||
}
|
||||
|
||||
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
|
||||
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
|
||||
// supported (App's LOG_QUERY uses exactly these).
|
||||
// Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
|
||||
// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
|
||||
// by adapter (firebase.js slices) — mock never sees it.
|
||||
function applyConstraints(docs, constraints) {
|
||||
let out = [...docs];
|
||||
for (const c of constraints) {
|
||||
|
||||
@@ -294,6 +294,7 @@ function runStorageContract(name, factory) {
|
||||
describe('subscribeCollection queryConstraints', () => {
|
||||
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
||||
const limitC = (n) => ({ __type: 'limit', n });
|
||||
const offsetC = (n) => ({ __type: 'offset', offset: n });
|
||||
|
||||
beforeEach(async () => {
|
||||
// seed 5 log docs, timestamps out of order
|
||||
@@ -324,6 +325,23 @@ function runStorageContract(name, factory) {
|
||||
});
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
|
||||
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
|
||||
// slice). Both MUST return the same page.
|
||||
test('offset skips first N after ordering (pagination)', async () => {
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
|
||||
});
|
||||
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
|
||||
});
|
||||
|
||||
test('offset past end returns empty page', async () => {
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+27
-21
@@ -71,6 +71,27 @@ export function getAuthInstance() { return authInstance; }
|
||||
// App.js can now import { storage } and call storage.setDoc(path, data).
|
||||
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
||||
|
||||
// Translate neutral {__type} constraints (src/storage/index.js builders) to
|
||||
// SDK builders. The client SDK has no offset, so emulate it: widen limit to
|
||||
// offset+limit and report offsetN for the caller to slice off the leading
|
||||
// docs. Read cost matches native offset (admin SDK also bills skipped docs);
|
||||
// the server adapter pushes offset into SQL instead. Unknown constraint types
|
||||
// are dropped — a raw object handed to query() throws and white-screens.
|
||||
function toSdkConstraints(queryConstraints) {
|
||||
let offsetN = 0;
|
||||
let limitN = null;
|
||||
const fbConstraints = [];
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||
else if (c.__type === 'limit') limitN = c.n;
|
||||
else if (c.__type === 'offset') offsetN = c.offset || 0;
|
||||
}
|
||||
if (limitN !== null) fbConstraints.push(limit(limitN + offsetN));
|
||||
return { fbConstraints, offsetN };
|
||||
}
|
||||
|
||||
export function createFirebaseStorage() {
|
||||
const db = dbInstance;
|
||||
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
||||
@@ -101,18 +122,10 @@ export function createFirebaseStorage() {
|
||||
},
|
||||
|
||||
async getCollection(collectionPath, queryConstraints = []) {
|
||||
// Translate neutral {__type} constraints to firebase SDK builders.
|
||||
const fbConstraints = [];
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
|
||||
// firebase SDK has no offset; skip (UI uses server adapter in dev).
|
||||
}
|
||||
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
||||
const snapshot = await getDocsReal(q);
|
||||
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
||||
return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }));
|
||||
},
|
||||
|
||||
async countCollection(collectionPath) {
|
||||
@@ -174,19 +187,12 @@ export function createFirebaseStorage() {
|
||||
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||
// queryConstraints = neutral {__type} builders (from index.js).
|
||||
// Translate to SDK orderBy/limit.
|
||||
const sdkConstraints = queryConstraints.map(c => {
|
||||
if (c.__type === 'where') return where(c.field, c.op, c.value);
|
||||
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
||||
if (c.__type === 'limit') return limit(c.n);
|
||||
return c; // pass-through (forward compat)
|
||||
});
|
||||
const q = sdkConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...sdkConstraints)
|
||||
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||
const q = fbConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...fbConstraints)
|
||||
: collection(db, collectionPath);
|
||||
return onSnapshot(q, (snap) => {
|
||||
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => {
|
||||
console.error(`subscribeCollection ${collectionPath}:`, err);
|
||||
if (typeof errCb === 'function') errCb(err);
|
||||
|
||||
Reference in New Issue
Block a user