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
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

@@ -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">