7 Commits
v1.1.0 ... dev

Author SHA1 Message Date
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
5 changed files with 48 additions and 23 deletions

View File

@@ -55,7 +55,6 @@ ENCRYPTION_SECRET_KEY= # generate: python -c "from cryptography.fernet import F
# PostgreSQL # PostgreSQL
POSTGRES_USER=congress POSTGRES_USER=congress
POSTGRES_PASSWORD=your-strong-password
POSTGRES_DB=pocketveto POSTGRES_DB=pocketveto
# Redis # Redis
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini 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): Other providers (swap in place of the OpenAI block):
```env ```env
# Anthropic # Anthropic
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
docker compose up --build -d 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: This will:
1. Pull base images (postgres, redis, nginx, node) 1. Pull base images (postgres, redis, nginx, node)
2. Build the API, worker, beat, and frontend images 2. Build the API, worker, beat, and frontend images

View File

@@ -10,8 +10,14 @@ import app.models # noqa: F401 — registers all models with Base.metadata
config = context.config 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") 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: if sync_url:
config.set_main_option("sqlalchemy.url", sync_url) config.set_main_option("sqlalchemy.url", sync_url)

View File

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

View File

@@ -126,21 +126,21 @@ function ModeFilterSection({
<input type="checkbox" checked={!!filters["new_document"]} <input type="checkbox" checked={!!filters["new_document"]}
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })} onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
className="mt-0.5 rounded" /> 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>
<label className="flex items-start gap-3 py-1.5 cursor-pointer"> <label className="flex items-start gap-3 py-1.5 cursor-pointer">
<input type="checkbox" checked={!!filters["new_amendment"]} <input type="checkbox" checked={!!filters["new_amendment"]}
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })} onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
className="mt-0.5 rounded" /> 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> </label>
<div className="py-1.5"> <div className="py-1.5">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"} <input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
onChange={toggleMilestones} className="mt-0.5 rounded" /> 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> </label>
<div className="ml-6 mt-1 space-y-0.5"> <div className="ml-6 mt-1 space-y-0.5">
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => { {(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
@@ -150,7 +150,7 @@ function ModeFilterSection({
<input type="checkbox" checked={!!filters[k]} <input type="checkbox" checked={!!filters[k]}
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })} onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
className="mt-0.5 rounded" /> 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> </label>
); );
})} })}
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
</div> </div>
{/* Channel tab bar */} {/* 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: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled }, { key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
<button <button
key={key} key={key}
onClick={() => setActiveChannelTab(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 activeChannelTab === key
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
</div> </div>
{/* Tab bar */} {/* 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) => ( {MODES.map((mode) => (
<button <button
key={mode.key} key={mode.key}
onClick={() => setActiveFilterTab(mode.key as ModeKey)} 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 activeFilterTab === mode.key
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
))} ))}
<button <button
onClick={() => setActiveFilterTab("discovery")} 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" activeFilterTab === "discovery"
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
<input type="checkbox" checked={!!enabled} <input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))} onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" /> 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-sm font-medium">Notify me about bills from member follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span> <span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div> </div>
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
{unmutedMembers.length > 0 && ( {unmutedMembers.length > 0 && (
<select value="" <select value=""
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }} 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> <option value="" disabled>Mute a member</option>
{unmutedMembers.map((f) => ( {unmutedMembers.map((f) => (
<option key={f.follow_value} value={f.follow_value}> <option key={f.follow_value} value={f.follow_value}>
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
<input type="checkbox" checked={!!enabled} <input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))} onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" /> 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-sm font-medium">Notify me about bills from topic follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span> <span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div> </div>
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
{unmutedTopics.length > 0 && ( {unmutedTopics.length > 0 && (
<select value="" <select value=""
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }} 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> <option value="" disabled>Mute a topic</option>
{unmutedTopics.map((f) => ( {unmutedTopics.map((f) => (
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option> <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"> <div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label> <label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))} <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>)} {HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select> </select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label> <label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))} <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>)} {HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select> </select>
</div> </div>
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
const billTitle = p.bill_title as string | undefined; const billTitle = p.bill_title as string | undefined;
const briefSummary = p.brief_summary as string | undefined; const briefSummary = p.brief_summary as string | undefined;
return ( 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}`} /> <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"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span> <span className="text-xs font-medium">{meta.label}</span>
{billLabel && ( {billLabel && (
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span> <span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div> </div>
{billTitle && ( {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; const src = p.source as string | undefined;
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
) : null; ) : null;
})()} })()}
{briefSummary && ( {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> </div>
{showDispatch && ( {showDispatch && (

View File

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