9 Commits
v1.1.0 ... main

Author SHA1 Message Date
Jack Levy
1afe8601ed chore: expand robots.txt — block AI scrapers and SEO bots
Add blocks for AI training crawlers (GPTBot, CCBot, Bytespider, anthropic-ai,
Google-Extended, PerplexityBot, YouBot, cohere-ai), SEO tool bots (AhrefsBot,
SemrushBot, DotBot, MJ12bot, BLEXBot), and /_next/ static chunks. Add
Crawl-delay: 10 for well-behaved bots.

Authored by: Jack Levy
2026-03-15 21:49:05 -04:00
Jack Levy
989419665e chore: add robots.txt, update docs with mobile layout fix
- Add frontend/public/robots.txt: allow public pages, block auth/user-private routes and /api/
- ROADMAP.md: add mobile layout fix to UX & Polish shipped items
- TROUBLESHOOTING.md: add section on Android Chrome viewport overflow root cause and fix

Authored by: Jack Levy
2026-03-15 21:36:46 -04:00
Jack Levy
7ff75f9a00 fix: mobile layout — prevent horizontal overflow on notifications page
- AuthGuard: switch outer shell from h-dvh to fixed inset-0 so the
  container always matches the visual viewport regardless of Android
  Chrome address-bar state; add min-w-0 to content column so flex
  children (e.g. long ntfy URL input) cannot force the column wider
  than the viewport
- Notifications page: add overflow-x-auto + shrink-0 to both tab bars
  so button overflow scrolls within the bar instead of escaping to the
  page; add min-w-0 to all inline label/hint div pairs in
  ModeFilterSection and Discovery so they shrink correctly in flex
  layout; add break-all to bill title line-clamp paragraph

Authored by: Jack Levy
2026-03-15 21:09:23 -04:00
Jack Levy
761ffa85d9 fix: prevent horizontal overflow in notification history rows on mobile
Add overflow-hidden to EventRow containers and break-words to brief summary
to prevent long unbreakable text from widening the viewport and causing zoom.
Also switch h-screen to h-dvh for correct mobile viewport height.

Authored by: Jack Levy
2026-03-15 19:58:34 -04:00
Jack Levy
21dd784fbb fix: use h-dvh instead of h-screen to fix iOS Safari bottom cutoff
100vh on iOS Safari includes browser chrome, cutting off page content.
dvh (dynamic viewport height) adjusts correctly as the toolbar appears/disappears.

Authored by: Jack Levy
2026-03-15 19:51:58 -04:00
Jack Levy
e12567ea3c fix: prevent iOS auto-zoom on notifications page select elements
iOS Safari zooms the page when focusing a select/input with font-size < 16px.
Bump all select elements on the notifications page to text-base (16px).

Authored by: Jack Levy
2026-03-15 19:48:25 -04:00
Jack Levy
7844367bd2 docs: update DEPLOYING.md for secrets file password approach
Authored by: Jack Levy
2026-03-15 19:16:16 -04:00
Jack Levy
80343d3782 fix: alembic reads DB URL from config.py (secrets file) not hardcoded alembic.ini
Authored by: Jack Levy
2026-03-15 19:05:01 -04:00
Jack Levy
a2146a4f0b fix: add restart: unless-stopped to all services
Ensures all containers come back up automatically after a server reboot.

Authored by: Jack Levy
2026-03-15 18:55:28 -04:00
8 changed files with 149 additions and 23 deletions

View File

@@ -55,7 +55,6 @@ ENCRYPTION_SECRET_KEY= # generate: python -c "from cryptography.fernet import F
# PostgreSQL
POSTGRES_USER=congress
POSTGRES_PASSWORD=your-strong-password
POSTGRES_DB=pocketveto
# Redis
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini
```
### Database password (secrets file)
The database password lives in a plain file rather than `.env` so that any characters work with no escaping needed.
```bash
mkdir -p secrets
openssl rand -base64 32 | tr -d '+/=' | cut -c1-32 > secrets/db_password
chmod 600 secrets/db_password
```
The `secrets/` directory is gitignored. Docker mounts `secrets/db_password` read-only into each container; the backend reads it automatically to build the database URL. Never commit this file.
Other providers (swap in place of the OpenAI block):
```env
# Anthropic
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
docker compose up --build -d
```
> **Note:** Make sure `secrets/db_password` exists before running this command (see step 2). Docker Compose will fail to start if the secrets file is missing.
This will:
1. Pull base images (postgres, redis, nginx, node)
2. Build the API, worker, beat, and frontend images

View File

@@ -71,6 +71,7 @@
- [x] Collapsible sidebar — icon-only mode, `localStorage` persistence
- [x] Favicon — Landmark icon
- [x] LLM Batch API — OpenAI + Anthropic async batch endpoints; 50% cost reduction; state tracked in `AppSetting("llm_active_batch")`
- [x] Mobile layout fix — `min-w-0` on flex content column in `AuthGuard` prevents long URLs from forcing viewport overflow on Android Chrome; app shell switched to `fixed inset-0` for stable visual-viewport anchoring
---

View File

@@ -132,6 +132,44 @@ docker compose logs -f api worker
---
## Mobile page wider than viewport (Android Chrome zoom / dead space)
**Symptom**
On Android Chrome, a page zooms out to fit an oversized layout. The content is readable but the bottom third of the screen is dead space (background color only). The same page looks fine on all other devices, and other pages on the same device are fine.
**Root cause**
Flex items default to `min-width: auto`, meaning they cannot shrink below their content's natural width. If any descendant (e.g. a long URL in an input field) is wider than the viewport, the containing flex column expands to match, making the entire page layout wider than the viewport. Android Chrome then zooms the viewport out to fit the full layout width — which makes the layout taller than the visual viewport, leaving dead space at the bottom.
**Fix**
Add `min-w-0` (Tailwind) / `min-width: 0` (CSS) to the flex item that forms the main content column. This allows the column to shrink to zero, enabling children to wrap and truncate normally.
In `AuthGuard.tsx` the content column needs both `flex-1` and `min-w-0`:
```tsx
// Before — missing min-w-0
<div className="flex-1 flex flex-col min-h-0">
// After
<div className="flex-1 flex flex-col min-h-0 min-w-0">
```
Also switch the outer shell from `h-dvh` to `fixed inset-0 flex overflow-hidden` so the shell is anchored to the visual viewport directly (avoids `dvh` staleness issues on Android):
```tsx
// Before
<div className="flex h-dvh bg-background">
// After
<div className="fixed inset-0 flex overflow-hidden bg-background">
```
**General rule**: any `flex-1` column that can contain user-supplied text or long URLs should also carry `min-w-0`.
---
## Inspecting the database
```bash

View File

@@ -10,8 +10,14 @@ import app.models # noqa: F401 — registers all models with Base.metadata
config = context.config
# Override sqlalchemy.url from environment if set
# Override sqlalchemy.url — prefer env var, then config.py (which reads secrets file)
sync_url = os.environ.get("SYNC_DATABASE_URL")
if not sync_url:
try:
from app.config import settings as app_settings
sync_url = app_settings.SYNC_DATABASE_URL
except Exception:
pass
if sync_url:
config.set_main_option("sqlalchemy.url", sync_url)

View File

@@ -9,6 +9,7 @@ services:
- db_password
volumes:
- ./postgres/data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
interval: 5s
@@ -19,6 +20,7 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- ./redis/data:/data
healthcheck:
@@ -41,6 +43,7 @@ services:
- POSTGRES_HOST=postgres
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- REDIS_URL=redis://redis:6379/0
restart: unless-stopped
secrets:
- db_password
depends_on:
@@ -61,6 +64,7 @@ services:
- POSTGRES_HOST=postgres
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- REDIS_URL=redis://redis:6379/0
restart: unless-stopped
secrets:
- db_password
depends_on:
@@ -81,6 +85,7 @@ services:
- POSTGRES_HOST=postgres
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- REDIS_URL=redis://redis:6379/0
restart: unless-stopped
secrets:
- db_password
depends_on:
@@ -93,6 +98,7 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
environment:
- NODE_ENV=production
depends_on:

View File

@@ -126,21 +126,21 @@ function ModeFilterSection({
<input type="checkbox" checked={!!filters["new_document"]}
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
className="mt-0.5 rounded" />
<div><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
<div className="min-w-0"><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
</label>
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
<input type="checkbox" checked={!!filters["new_amendment"]}
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
className="mt-0.5 rounded" />
<div><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
<div className="min-w-0"><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
</label>
<div className="py-1.5">
<label className="flex items-start gap-3 cursor-pointer">
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
onChange={toggleMilestones} className="mt-0.5 rounded" />
<div><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
<div className="min-w-0"><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
</label>
<div className="ml-6 mt-1 space-y-0.5">
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
@@ -150,7 +150,7 @@ function ModeFilterSection({
<input type="checkbox" checked={!!filters[k]}
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
className="mt-0.5 rounded" />
<div><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
<div className="min-w-0"><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
</label>
);
})}
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
</div>
{/* Channel tab bar */}
<div className="flex gap-0 border-b border-border -mx-6 px-6">
<div className="flex gap-0 border-b border-border -mx-6 px-6 overflow-x-auto">
{([
{ key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
<button
key={key}
onClick={() => setActiveChannelTab(key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
className={`shrink-0 flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeChannelTab === key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
</div>
{/* Tab bar */}
<div className="flex gap-0 border-b border-border">
<div className="flex gap-0 border-b border-border overflow-x-auto">
{MODES.map((mode) => (
<button
key={mode.key}
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeFilterTab === mode.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
))}
<button
onClick={() => setActiveFilterTab("discovery")}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeFilterTab === "discovery"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
<input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" />
<div>
<div className="min-w-0">
<span className="text-sm font-medium">Notify me about bills from member follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div>
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
{unmutedMembers.length > 0 && (
<select value=""
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
<option value="" disabled>Mute a member</option>
{unmutedMembers.map((f) => (
<option key={f.follow_value} value={f.follow_value}>
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
<input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" />
<div>
<div className="min-w-0">
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div>
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
{unmutedTopics.length > 0 && (
<select value=""
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
<option value="" disabled>Mute a topic</option>
{unmutedTopics.map((f) => (
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
@@ -884,14 +884,14 @@ export default function NotificationsPage() {
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
const billTitle = p.bill_title as string | undefined;
const briefSummary = p.brief_summary as string | undefined;
return (
<div className="flex items-start gap-3 py-3">
<div className="flex items-start gap-3 py-3 overflow-hidden">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span>
{billLabel && (
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
{billTitle && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1 break-all">{billTitle}</p>
)}
{(() => {
const src = p.source as string | undefined;
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
) : null;
})()}
{briefSummary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{briefSummary}</p>
)}
</div>
{showDispatch && (

View File

@@ -42,7 +42,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
// Authenticated or guest browsing: render the full app shell
return (
<div className="flex h-screen bg-background">
<div className="fixed inset-0 flex overflow-hidden bg-background">
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden md:flex">
<Sidebar />
@@ -59,7 +59,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
)}
{/* Content column */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
<main className="flex-1 overflow-auto">
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">

View File

@@ -0,0 +1,62 @@
User-agent: *
Allow: /
Allow: /bills
Allow: /bills/
Allow: /members
Allow: /members/
Allow: /topics
Allow: /how-it-works
Allow: /share/
Disallow: /login
Disallow: /register
Disallow: /settings
Disallow: /notifications
Disallow: /following
Disallow: /collections
Disallow: /alignment
Disallow: /api/
Disallow: /_next/
Crawl-delay: 10
# AI training crawlers
User-agent: GPTBot
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: YouBot
Disallow: /
User-agent: cohere-ai
Disallow: /
# SEO tool crawlers
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: DotBot
Disallow: /
User-agent: MJ12bot
Disallow: /
User-agent: BLEXBot
Disallow: /