Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ dist/
htmlcov/
*.bak

# Node / frontend
node_modules/

# OS / Editor
.DS_Store
.AppleDouble
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ field-ops = [
"bcrypt>=4.0.0",
"python-multipart>=0.0.9",
"pyyaml>=6.0.1",
# Cloudflare R2 photo storage via its S3-compatible API. Imported lazily in
# storage.py, so local development never loads it.
"boto3>=1.34.0",
"typer>=0.21.1",
"qrcode[pil]>=7.4.2",
]
Expand Down
247 changes: 247 additions & 0 deletions services/field-ops/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# Deploying Field Ops for the Palawan fieldwork

**Written 2026-08-06.** Target: a URL that field staff can open on their phones
before they leave, and that keeps working when they have no signal.

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language to the architecture diagram fence.

Line 6 opens a fenced code block without a language. This triggers markdownlint MD040. Change the opener to ```text.

Proposed fix
-```
+```text
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 6-6: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` at line 6, Update the opening fenced code block
in DEPLOY.md to specify the text language by changing the fence opener to
```text, resolving the MD040 markdownlint violation.

Source: Linters/SAST tools

Vercel → the PWA (static build)
Fly / Railway → FastAPI container (the existing Dockerfile)
Neon → Postgres
Cloudflare R2 → photo blobs
```

**Nothing here creates accounts or handles credentials on your behalf.** Every
step you must do yourself is marked **[you]**. Values go into each platform's
secret store; none of them belong in this repo.

---

## 0. Read this before provisioning anything

Three things were broken for field use and are now fixed. They are worth knowing
because they change what you should test:

| Was | Now |
|---|---|
| **No login screen at all** — `login()` existed but nothing called it; the only way in was to hand-craft a JWT into `localStorage` | Real sign-in form; one account per observer |
| **Offline submissions destroyed the photo** — the blob was never queued, and `reset()` cleared it, while the UI said "Saved offline" | Photo is queued in IndexedDB with the record and uploaded on sync |
| **Photos written to local disk** — fine on a laptop, gone on the next container restart | Configurable backend; **production refuses to start unless it is R2** |

The Queue tab now lists what is on the device and what has synced, so an
operator can confirm their day's work exists before leaving a site.

---

## 1. Neon — Postgres **[you]**

1. Create a project at neon.tech. Region: **Singapore** (`ap-southeast-1`) — the
closest to Palawan; every extra 100 ms shows up on a weak link.
2. Copy the connection string. It looks like
`postgresql://user:pass@ep-xxx.ap-southeast-1.aws.neon.tech/neondb?sslmode=require`
3. Keep it in your password manager. It is a credential — do not paste it into
chat, a commit, or a screenshot.

**Use the pooled endpoint** (Neon shows it as "Pooled connection", host contains
`-pooler`). A container that restarts holds connections open; the direct
endpoint runs out.

`?sslmode=require` and `channel_binding` are stripped automatically — asyncpg
rejects libpq's parameters and the resulting error is not obvious.

### Run the migrations **[you]**

From this repo, with `DATABASE_URL` exported in your shell:

```bash
export DATABASE_URL='postgresql://...' # from Neon, pooled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline / relevant lines =="
wc -l services/field-ops/DEPLOY.md
sed -n '1,150p' services/field-ops/DEPLOY.md

echo
echo "== credential occurrences =="
rg -n "DATABASE_URL|R2_ACCESS_KEY_ID|R2_SECRET_ACCESS_KEY|fly secrets|secrets import|export " services/field-ops/DEPLOY.md || true

echo
echo "== surrounding scripts/config files =="
fd -a '(\.env|fly\.toml|fly\.toml\.sample|.*env.*|.*deploy.*|.*deploy)' services/field-ops || true

Repository: alfieprojectsdev/movefaults

Length of output: 6452


Do not guide operators to paste credentials into shell commands.

Line 56 exports DATABASE_URL in the shell, and lines 114-122 pass DATABASE_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY directly to fly secrets set. These values can remain in shell history or process arguments. Use the provider’s secret-import/stdin flow or protected environment variables instead, such as Fly’s fly secrets import, which reads NAME=VALUE pairs from standard input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` at line 56, Update the deployment instructions
around the DATABASE_URL example and the fly secrets set usage to avoid putting
credentials in shell commands or arguments. Use Fly’s protected
secret-import/stdin flow, such as fly secrets import, with DATABASE_URL,
R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY supplied through protected
environment variables or standard input.

uv run alembic upgrade head # core schema, 13 revisions
uv run alembic -c services/field-ops/alembic.ini upgrade head # field_ops schema
```

Verify before moving on — a partial migration surfaces as a confusing 500 later:

```bash
psql "$DATABASE_URL" -c "\dt field_ops.*"
```

Expect 7 tables: `users`, `logsheets`, `staff`, `logsheet_observers`,
`equipment_inventory`, `logsheet_photos`, `equipment_history`.

### Seed stations and staff **[you]**

The station list is what the observers pick from — it must contain the Palawan
sites they are actually visiting, which the 10 demo rows do **not**.

```sql
INSERT INTO public.stations (station_code, name, municipality, province, monitoring_method, status)
VALUES ('XXXX', 'Site name', 'Municipality', 'Palawan', 'campaign', 'active');

INSERT INTO field_ops.staff (full_name, initials, role, is_active)
VALUES ('Full Name', 'FN', 'field_staff', true);
```

---

## 2. Cloudflare R2 — photos **[you]**

1. Cloudflare dashboard → R2 → **Create bucket**, e.g. `pogf-field-ops`.
Location hint: **APAC**.
2. **Keep it private.** Site photos can show equipment, access routes and
occasionally people. Reads go through the API, not a public bucket URL.
3. R2 → **Manage API Tokens** → Create token, permission **Object Read & Write**,
scoped to that one bucket.
4. Note four values: account ID, access key ID, secret access key, bucket name.
The secret is shown **once**.

Free tier is 10 GB — roughly 3,300 phone photos at ~3 MB. A week in Palawan will
not come close.

---

## 3. Backend container **[you]**

The existing `services/field-ops/Dockerfile` builds unmodified. Fly.io shown;
Railway and Render are equivalent.

```bash
cd services/field-ops
fly launch --no-deploy --name pogf-field-ops --region sin
```

Set secrets — **names here, values from your password manager**:

```bash
fly secrets set \
FIELD_OPS_PRODUCTION=1 \
DATABASE_URL='...' \
FIELD_OPS_JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" \
FIELD_OPS_STORAGE_BACKEND=r2 \
R2_ACCOUNT_ID='...' \
R2_ACCESS_KEY_ID='...' \
R2_SECRET_ACCESS_KEY='...' \
R2_BUCKET='pogf-field-ops'
```

`FIELD_OPS_PRODUCTION=1` is the important one. It turns on a **fail-closed**
startup check that refuses to boot if the JWT secret is the shipped default or
under 32 characters, if storage is not R2, or if `DATABASE_URL` is missing.

That check exists because this repo is **public**. With the default secret,
anyone who reads it can mint a valid token for your URL and post logsheets as
any user. A service that boots happily in that state is worse than one that
refuses, because nobody finds out.

```bash
fly deploy
curl https://<your-app>.fly.dev/health # {"status":"ok",...}
```

If it will not start, read the logs — the refusal message names every missing
variable at once, and never prints a value.

---

## 4. Frontend on Vercel **[you]**

1. Import the repo. **Root directory: `services/field-ops/frontend`.**
2. Edit `vercel.json` first — replace `REPLACE-WITH-BACKEND-HOST` with your
backend hostname:

```json
{ "source": "/api/:path*", "destination": "https://pogf-field-ops.fly.dev/api/:path*" }
```

The rewrite keeps the API same-origin, so there is no CORS preflight — one
fewer round trip on one bar of signal. If you deploy cross-origin instead, set
`VITE_API_BASE_URL` at build time and add the Vercel URL to
`FIELD_OPS_CORS_ORIGINS` on the backend.

3. Deploy. Note the URL.

---

## 5. Create the observer accounts **[you]**

One per person, so `submitted_by` records who filed each sheet. There is no
sign-up screen by design.

```bash
python3 -c "import bcrypt,secrets; pw=secrets.token_urlsafe(9); \
print('password:', pw); print('hash:', bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode())"
Comment on lines +169 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate DEPLOY.md and dependency/config files =="
fd -a 'DEPLOY\.md$|pyproject\.toml|uv\.lock|requirements.*|README.*' . | sed 's#^\./##' | head -200

echo
echo "== target lines =="
if [ -f services/field-ops/DEPLOY.md ]; then
  nl -ba services/field-ops/DEPLOY.md | sed -n '150,180p'
fi

echo
echo "== relevant config files =="
for f in $(fd 'pyproject\.toml|uv\.lock|README.*' . | head -100); do
  echo "--- $f"
  sed -n '1,220p' "$f" | nl -ba | sed -n '1,220p'
done

echo
echo "== references to python3, uv run, bcrypt in deploy/docs =="
rg -n 'python3|uv run|uv|bcrypt|account|create|password|dependency' -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: alfieprojectsdev/movefaults

Length of output: 1081


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target lines =="
awk 'NR>=150 && NR<=180 {printf "%5d\t%s\n", NR, $0}' services/field-ops/DEPLOY.md || sed -n '150,180p' services/field-ops/DEPLOY.md | awk '{printf "%5d\t%s\n", NR+149, $0}'

echo "== pyproject and lock files =="
for f in pyproject.toml uv.lock services/vadase-rt-monitor/uv.lock; do
  [ -f "$f" ] || continue
  echo "--- $f"
  sed -n '1,260p' "$f"
done

echo "== project files =="
find . -maxdepth 3 \( -name 'pyproject.toml' -o -name 'uv.lock' \) -print | sort | while read -r f; do
  echo "--- $f"
  sed -n '1,220p' "$f"
done

echo "== references =="
rg -n 'python3|uv run|uv|bcrypt|accounts|account|password|dependency|install|create' -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: alfieprojectsdev/movefaults

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target lines =="
awk 'NR>=150 && NR<=180 {printf "%5d\t%s\n", NR, $0}' services/field-ops/DEPLOY.md

echo "== pyproject and uv.lock summaries (no rg across whole repo) =="
sed -n '1,220p' pyproject.toml
echo "--- uv.lock project packages around bcrypt/tools ---"
sed -n '1,260p' uv.lock | sed -n '/^name = /p;/^version = /p;/^source = /p;/^dependencies = /p;/^extra = /p;/optional = /' | head -200
echo "--- services/vadase-rt-monitor packages ---"
sed -n '1,220p' services/vadase-rt-monitor/pyproject.toml || true
sed -n '1,220p' services/vadase-rt-monitor/uv.lock || true

echo "== focused references in relevant docs and configs =="
rg -n 'python3|uv run|uv|bcrypt|accounts|account|password|dependency|install|create' -S pyproject.toml uv.lock services/field-ops/DEPLOY.md services/vadase-rt-monitor README.md || true

echo "== deterministic import-check behavior for clean host Python without bcrypt =="
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("bcrypt")
print("bcrypt_spec_found=", bool(spec))
PY

Repository: alfieprojectsdev/movefaults

Length of output: 5771


Run password generation with the project interpreter.

This snippet imports bcrypt, but it does not use the project environment that declares field-ops deps. A clean host with only python3 can fail this step. Run it with uv run python -c ..., or add an explicit dependency check before the command.

Proposed command change
-python3 -c "import bcrypt,secrets; pw=secrets.token_urlsafe(9); \
+uv run python -c "import bcrypt,secrets; pw=secrets.token_urlsafe(9); \
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
python3 -c "import bcrypt,secrets; pw=secrets.token_urlsafe(9); \
print('password:', pw); print('hash:', bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode())"
uv run python -c "import bcrypt,secrets; pw=secrets.token_urlsafe(9); \
print('password:', pw); print('hash:', bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode())"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` around lines 169 - 170, Update the
password-generation command in the deployment instructions to invoke the project
interpreter via uv run python, ensuring the declared field-ops dependencies such
as bcrypt are available.

Source: Coding guidelines

```

```sql
INSERT INTO field_ops.users (username, hashed_password, role)
VALUES ('surname', '<hash>', 'field_staff');
```

Give each person their own password **through a private channel**, not a group
chat. Sessions last 8 hours — a full shift without re-entering anything.

---

## 6. Verify before you send the URL

Do all of this on a real phone, not a desktop browser. **Step 4 is the one that
matters most and the one most likely to be skipped.**

- [ ] Open the URL, sign in with a real observer account
- [ ] Station dropdown lists the **Palawan** sites, not the demo ten
- [ ] "Add to Home Screen" installs it; it opens without browser chrome
- [ ] **Enable airplane mode.** Fill a full campaign sheet, attach a photo,
submit. Expect *"Saved offline — including the photo."* Check the Queue
tab shows it as **photo held**
- [ ] Turn signal back on. The queue should drain by itself; the record moves to
synced
- [ ] Confirm in the DB that the row exists **and** `logsheet_photos` has a row
with an `r2://` path
- [ ] Open the object in the R2 dashboard and confirm it is the right image
- [ ] Force-quit the app between queueing and syncing, reopen — the queued sheet
must still be there

If step 4 fails, stop and fix it before anyone travels. That is the path they
will use most, and its failure mode is silent.

---

## 7. Brief the staff on three things

1. **Sign in once while you still have signal.** The app then works offline for
the rest of the day, but the first login needs a connection.
2. **Do not clear browsing data, and do not use private/incognito.** Queued
sheets live in the browser's storage; clearing it destroys unsynced work.
3. **Check the Queue tab before leaving a site.** If it shows pending items,
that is normal — but the count should drop to zero once back in signal.

---

## 8. Known gaps — decide before, not during

- **No frontend tests.** The slant→RH computation is the only real domain
arithmetic in the UI and has none. A wrong answer there silently corrupts the
vertical component of every campaign occupation. It is verified by hand for
`TRM55971-00` only.
Comment on lines +220 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'slant|TRM55971|relative.?humidity|\bRH\b' services/field-ops/frontend/src || true
rg -n -C 3 'vitest|jest|playwright|test:' \
  services/field-ops/frontend/package.json services/field-ops/frontend || true

Repository: alfieprojectsdev/movefaults

Length of output: 17354


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== LogSheetForm slice =="
sed -n '1,80p' services/field-ops/frontend/src/components/LogSheetForm.tsx
sed -n '160,235p' services/field-ops/frontend/src/components/LogSheetForm.tsx

echo
echo "== frontend package and test files =="
fd -a '^(package\.json|vitest\.config\..*|jest\.config\..*|playwright\.config\..*|setup.*\.(js|ts|mjs|cjs))$' services/field-ops/frontend | sed 's#^\./##' | sort
sed -n '1,180p' services/field-ops/frontend/package.json 2>/dev/null || true
rg -n -C 2 'describe|it\s*\(|test\s*\(|expect\(|const .*to\s*be|assert' services/field-ops/frontend/src services/field-ops/frontend 2>/dev/null || true

echo
echo "== deploy lines around test coverage note =="
sed -n '200,235p' services/field-ops/DEPLOY.md

echo
echo "== static search for calculateHelper / antenna math in tests and src =="
rg -n 'calculate|antenna|rinex_height|avg_slant|slantN|VO|Math\.sqrt|rhValue' services/field-ops/frontend/src services/field-ops/frontend -g '!node_modules' || true

Repository: alfieprojectsdev/movefaults

Length of output: 21260


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== submit validation slice =="
sed -n '235,290p' services/field-ops/frontend/src/components/LogSheetForm.tsx

echo
echo "== LogSheetIn schema in api =="
sed -n '35,70p' services/field-ops/frontend/src/services/api.ts

echo
echo "== JS numeric/NaN behavior used by current slant parser =="
node - <<'JS'
const samples = [
  ["0", "0", "0", "0"],
  ["0", "0", "0", "0.1698"],
  ["0", "0", "0", "0.1699"],
  ["1", "1", "1", "1"],
  ["0", "", "0", "0"],
  ["0", "+.", "0", "0"],
  ["0", "-", "0", "0"],
  ["0", "1.4320", "1.4318", "1.4322"],
];
for (const [N,E,S,W] of samples) {
  const Nf = parseFloat(N), Ef = parseFloat(E), Sf = parseFloat(S), Wf = parseFloat(W);
  const allSlantsFilled = Nf === Nf && Ef === Ef && Sf === Sf && Wf === Wf;
  const avgSH = allSlantsFilled ? (Nf + Ef + Sf + Wf) / 4 : undefined;
  const C = 0.1698, VO = 0.0444;
  const rh = avgSH !== undefined ? Math.sqrt(avgSH * avgSH - C * C) - VO : undefined;
  console.log(JSON.stringify({N,E,S,W,Nf,Ef,Sf,Wf,allSlantsFilled,avgSH,rh}));
}
JS

Repository: alfieprojectsdev/movefaults

Length of output: 4447


Add automated coverage for the slant-to-RH calculation before field deployment.

The campaign submit path computes and submits avg_slant_m and rinex_height_m directly with no frontend tests or test runner in the frontend package. Add TDD-style coverage for each supported antenna model, normal and boundary slant values, and invalid or partial inputs before release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` around lines 220 - 223, Add frontend
test-runner coverage for the campaign submit calculation that produces
avg_slant_m and rinex_height_m. Add TDD-style tests covering every supported
antenna model, normal and boundary slant values, plus invalid and partial
inputs, and ensure the tests assert the submitted values. Configure the frontend
package to run these tests before field deployment.

Source: Coding guidelines

- **No password reset.** Locked out means you issue a new hash by hand. For a
one-week trial that is acceptable; make sure someone with DB access is
reachable.
- **No admin view.** Reading the data means SQL against Neon.
- **Free tiers sleep.** A Neon or Fly instance idle for hours takes a few
seconds on first request. Harmless, but tell staff so a slow first load is not
read as a failure.
- **Photos are never deleted.** No retention policy; at ~3 MB each the 10 GB
free tier is fine for this trip and will need a decision later.
Comment on lines +231 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Resolve photo retention and archive protection before deployment.

Lines 231-232 state that photos are never deleted. Lines 240-243 export the database and photos to local paths without an explicit encryption, access-control, or cleanup step. Lines 89-90 state that photos can show people. Define a retention period and deletion procedure for both R2 objects and field_ops.logsheet_photos. Specify encrypted, access-controlled archive handling before field use.

Also applies to: 240-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` around lines 231 - 232, Update the deployment
documentation covering photo storage, database/photo exports, and the
people-visible photo handling to define a retention period and deletion
procedure for both R2 objects and field_ops.logsheet_photos. Specify that
exported archives are encrypted, access-controlled, and securely cleaned up
after use before field deployment, replacing the current “never deleted”
guidance.


---

## 9. After the fieldwork

Export before tearing anything down:

```bash
pg_dump "$DATABASE_URL" --schema=field_ops --schema=public -Fc -f palawan-$(date +%Y%m%d).dump
rclone sync r2:pogf-field-ops ./palawan-photos/ # or the R2 dashboard

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate file =="
git ls-files | rg '(^|/)DEPLOY\.md$' || true

echo "== Inspect relevant DEPLOY section =="
if [ -f services/field-ops/DEPLOY.md ]; then
  sed -n '210,260p' services/field-ops/DEPLOY.md
fi

echo "== Search for rclone commands in DEPLOY.md =="
rg -n "rclone\s+(sync|copy)\b|palawan-photos|r2:pogf-field-ops" services/field-ops/DEPLOY.md || true

echo "== Check surrounding section headings/context =="
rg -n "^(#{1,6} .+)$|palawan|photos|R2|rclone" services/field-ops/DEPLOY.md | head -200

Repository: alfieprojectsdev/movefaults

Length of output: 3772


🌐 Web query:

rclone sync deletes files in destination that are not in source copy documentation

💡 Result:

Yes, the rclone sync command is explicitly designed to make the destination match the source exactly, which includes deleting any files in the destination that are not present in the source [1][2][3]. Key details regarding this behavior include: - Purpose: The sync command modifies the destination only to ensure it is identical to the source [1][4]. - Deletion Behavior: If a file exists in the destination but not in the source, rclone will delete it from the destination [1][2]. - Prevention: If you do not want to delete files from the destination, you should use the rclone copy command instead, which copies files to the destination without deleting any existing files [1][2]. - Safety Mechanisms: Because this command can lead to data loss, it is highly recommended to use the --dry-run flag first to preview the changes that will be made [4][5]. Additionally, you can use the --backup-dir flag to move files that would have been deleted into a separate backup directory, or use --max-delete to limit the number of files that can be deleted in a single operation [1][2][5]. If you encounter errors during the process, rclone will typically not delete files in the destination to avoid unintended data loss [1][2]. Additionally, files that are excluded via filter rules are not deleted unless the --delete-excluded flag is specifically used [1][2].

Citations:


Use a non-destructive archive copy.

rclone sync makes the destination match the source and removes destination files that are not in r2:pogf-field-ops; repeated or alternate runs can delete an earlier export or manually preserved photo. Use a non-destructive command for this archive step, such as rclone copy into a dated directory, and preview with --dry-run before running.

Safer command
-rclone sync r2:pogf-field-ops ./palawan-photos/     # or the R2 dashboard
+rclone copy r2:pogf-field-ops "./palawan-photos-$(date +%Y%m%d-%H%M%S)/"  # or the R2 dashboard
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rclone sync r2:pogf-field-ops ./palawan-photos/ # or the R2 dashboard
rclone copy r2:pogf-field-ops "./palawan-photos-$(date +%Y%m%d-%H%M%S)/" # or the R2 dashboard
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/field-ops/DEPLOY.md` at line 242, Replace the destructive rclone
sync command in the archive instructions with a non-destructive copy workflow,
using rclone copy to a dated destination directory. Include a --dry-run preview
before the actual copy and preserve the existing R2 source and archive context.

```

Then follow the project's own rule: the archive needs a `sha256sum` manifest
committed to git, not just the files. Fingerprints stored only beside the data
prove nothing if that disk is what failed.
22 changes: 22 additions & 0 deletions services/field-ops/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@
<meta name="theme-color" content="#1a56a4" />
<link rel="manifest" href="/manifest.json" />
<title>POGF Field Ops</title>
<script>
// Set the theme before first paint. Without this the page renders light
// for one frame and then flips — a white flash straight into the eyes of
// someone working at dawn or at night, which is exactly who dark mode is
// for. Deliberately inline and dependency-free so it runs before the
// bundle loads; useTheme.ts takes over once React mounts.
(function () {
try {
var stored = localStorage.getItem("field_ops_theme");
var dark =
stored === "dark" ||
(stored !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
document
.querySelector('meta[name="theme-color"]')
.setAttribute("content", dark ? "#11191f" : "#1a56a4");
} catch (e) {
/* storage blocked — fall back to the stylesheet's media query */
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
Loading