# Project export: MyCity

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2025
- Tagline: MyCity uses AI journalists to monitor official information sources, distill complex content, and generate clear briefs that help locals stay informed about what’s happening around them.
- Devpost: https://devpost.com/software/mycity-av1qpx
- GitHub: https://github.com/Linlyo/CalHacks-table319-MyCity-Portal
- Team: 1 GitHub contributor(s) — linlyo (3 commits)

## Devpost submission (written by the team)

### Overview

I'm so glad that I managed to finish my first full-stack project as a first-time hackathon attendee.

### Inspiration

Local news deserts have been expanding rapidly across the U.S., with 3,300 newspapers closing over the past two decades. While short videos on emerging platforms offer quick updates, the MyCity portal serves as a reliable and accurate source of truth, helping people stay informed about what’s happening in their own cities.

### What it does

MyCity is a web app—soon to be primarily mobile—that crawls selected official information sources at carefully chosen intervals to deliver timely updates. Drawing on the expertise of seasoned journalists to identify authoritative sources, the app uses one-shot and few-shot prompt engineering to guide its professional writing. The result is relevant, bite-sized, and unbiased information briefs—free of fluff.

### How we built it

The stack includes Python, Claude API, SQLite, FastAPI, Vercel V0, and React.

### Challenges we ran into

The two secret ingredients of this project—gathering meaningful information based on lived human experience and producing strong content—also posed the biggest challenges. Properly guiding LLMs in professional news writing requires extra onboarding time. For instance, when condensing lengthy documents such as city council meeting minutes, considerable effort went into prompting the Claude API to generate high-quality summaries. Well-crafted few-shot prompts were essential in these cases.

### Accomplishments we're proud of

The current prototype includes content relevant to commuters, such as traffic updates and major public transportation alerts. In the Fremont city prototype, the current workflow can handle the daily output of two full-time journalists already. In a newsroom setting, this tool would allow professional journalists to focus on more creative and investigative tasks.

### What's next

We plan to expand the Fremont MyCity prototype with additional local sources and features. Another section of the app will allow users to receive updates from various local event calendars. Users can like or skip events to build a personalized MyCity event calendar. In the next phase, we will also develop a mobile-first version of the app.

## README (from the GitHub repository)

## Project overview
MyCity is a full-stack project I built solo at [the UC Berkeley AI Hackathon 2025](https://uc-berkeley-ai-hackathon-2025.devpost.com/), held on June 21–22. The prototype demonstrates a fully functional end-to-end workflow, covering data crawling, content generation, database integration, and a frontend web app. See link to my final submission [here](https://devpost.com/software/mycity-av1qpx?ref_content=my-projects-tab&ref_feature=my_projects).
## Inspiration
Local news deserts have been expanding rapidly across the U.S., with 3,300 newspapers closing over the past two decades. While short videos on emerging platforms offer quick updates, the MyCity app series serves as a reliable and accurate source of truth, helping people stay informed about what’s happening in their own cities.

## What it does
MyCity is a web app—soon to be primarily mobile—that crawls selected official information sources at carefully chosen intervals to deliver timely updates. Drawing on the expertise of seasoned journalists to identify authoritative sources, the app uses one-shot and few-shot prompt engineering to guide its professional writing. The result is relevant, bite-sized, and unbiased information briefs—free of fluff.

## Stack
Python, Claude API, SQLite, FastAPI, Node.js, React, and Vercel.

## Key challenges
The two secret ingredients of this project—gathering meaningful information based on lived human experience and producing strong content—also posed the biggest challenges. Properly guiding LLMs in professional news writing requires extra onboarding time. For instance, when condensing lengthy documents such as city council meeting minutes, considerable effort went into prompting the Claude API to generate high-quality summaries. Well-crafted few-shot prompts were essential in these cases.

## Accomplishments
The current prototype includes content relevant to commuters, such as traffic updates and major public transportation alerts. In the Fremont city prototype, the current workflow can handle the daily output of more than one full-time journalist already. In a newsroom setting, this tool would allow professional journalists to focus on more creative and investigative tasks.

## What's next for MyCity
An ideal plan would include expanding the Fremont MyCity prototype with additional local sources and features. Another section of the app will allow users to receive updates from various local event calendars. Users can like or skip events to build a personalized MyCity event calendar.  In the next phase, we will also develop a mobile-first version of the app. After that, it would be feasible to scale the app series and bring local news services to small cities across America.

## Steps finished by the end of the hackathon
* Web crawling workflow set up
* Prompt engineering done for the writing of select topics
* Database created
* Working API connected to the database
* Basic frontend built

![MyCity API](demo-api.png)


* Set up tutored writing via Claude API

Example text:
> The Fremont City Council approved several major infrastructure and service spending items totaling over $9 million at their May 20 regular meeting. All items on the agenda passed unanimously during the meeting.

> Key Items Approved:

> • **Road Repairs:** $4.986 million contract for the 2025 Cape and Slurry Seal Project to maintain city streets  
> • **Parkland Purchase:** $800,000 to buy property at Fremont Boulevard & St. Leonards Way for parks  
> • **Street Maintenance:** $1.03 million annually for 7-year landscape maintenance contract for city streetscapes  
> • **Equipment Purchases:** $836,000 for two new street sweepers; $245,000 for Wi-Fi upgrades at city facilities  
> • **Fuel Costs:** $1.463 million annual contract for gasoline and diesel fuel for city vehicles  
> • **Transportation Services:** $510,000 annually for Lyft ride-hailing services and $120,000 for call center services for seniors and disabled residents through 2028  

Mock main page:
![Main app page demo](domain-main-page-functional.png)







## Detected evidence (automated analysis)

Indexed codebase: 12 recognized source files, 39 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (24 of 24)

```
.claude/settings.local.json
.gitignore
.ipynb_checkpoints/data-crawling-building-database-checkpoint.ipynb
app.py
city_locals.db
city-alerts-frontend/.gitignore
city-alerts-frontend/components.json
city-alerts-frontend/eslint.config.mjs
city-alerts-frontend/next.config.ts
city-alerts-frontend/package.json
city-alerts-frontend/postcss.config.mjs
city-alerts-frontend/README.md
city-alerts-frontend/src/app/globals.css
city-alerts-frontend/src/app/layout.tsx
city-alerts-frontend/src/app/page.tsx
city-alerts-frontend/src/components/ui/badge.tsx
city-alerts-frontend/src/components/ui/card.tsx
city-alerts-frontend/src/components/ui/scroll-area.tsx
city-alerts-frontend/src/components/ui/tabs.tsx
city-alerts-frontend/src/lib/utils.ts
city-alerts-frontend/tsconfig.json
data-crawling-building-database.ipynb
demo-text-from-api.txt
README.md
```

### Dependencies

- city-alerts-frontend/package.json: @eslint/eslintrc@^3, @radix-ui/react-scroll-area@^1.2.9, @radix-ui/react-slot@^1.2.3, @radix-ui/react-tabs@^1.1.12, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, axios@^1.10.0, class-variance-authority@^0.7.1, clsx@^2.1.1, eslint@^9, eslint-config-next@15.3.4, lucide-react@^0.522.0, next@15.3.4, react@^19.0.0, react-dom@^19.0.0, tailwind-merge@^3.3.1, tailwindcss@^4, tw-animate-css@^1.3.4, typescript@^5

### Recent commits (newest first)

- adds link to readme
- final readme
- updates with working frontend
- frontend connected
- with mock main page; adds details to readme
- adds readme
- initial commit; frontend yet to be done

## Key source files (fetched from GitHub, selected and truncated for size)

### city-alerts-frontend/package.json

```
{
  "name": "city-alerts-frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@radix-ui/react-scroll-area": "^1.2.9",
    "@radix-ui/react-slot": "^1.2.3",
    "@radix-ui/react-tabs": "^1.1.12",
    "axios": "^1.10.0",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "lucide-react": "^0.522.0",
    "next": "15.3.4",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwind-merge": "^3.3.1"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3",
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.3.4",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.3.4",
    "typescript": "^5"
  }
}

```

### app.py

```python
# app.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import sqlite3
from typing import Optional, List
from pydantic import BaseModel

app = FastAPI(
    title="CityLocal API",
    description="API for city transportation alerts, relevant police reports, and city council news",
    version="1.0.0"
)

# CORS middleware - for interaction with frontend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # In production, specify your frontend URL like ["http://localhost:3000"]
    allow_credentials=True,
    allow_methods=["*"],  # Allow GET, POST, PUT, DELETE
    allow_headers=["*"],  # Allow all headers
)


def get_db_connection():
    try:
        conn = sqlite3.connect('city_locals.db')
        conn.row_factory = sqlite3.Row  # Returns dict-like rows instead of tuples
        return conn
    except sqlite3.Error as e:
        raise HTTPException(status_code=500, detail=f"Database connection failed: {str(e)}")

# Response model for better API docs
class Alert(BaseModel):
    id: int
    timestamp: str
    content: str
    alert_type: str
    is_major: bool
    is_active: bool

@app.get("/")
def root():
    return {"message": "CityLocal API is running!", "docs": "/docs"}

@app.get("/api/alerts", response_model=List[Alert])
def get_alerts(
    alert_type: Optional[str] = None,
    is_major: Optional[bool] = None,
    is_active: Optional[bool] = True,
    limit: Optional[int] = 100
):
    """
    Get alerts with optional filtering
    
    - **alert_type**: Filter by type (transit, traffic, police_report, city_council)
    - **is_major**: Filter by major alerts (True/False)
    - **is_active**: Filter by active status (default: True)
    - **limit**: Maximum number of results (default: 100)
    """
    conn = get_db_connection()
    
    try:
        query = "SELECT * FROM alerts WHERE 1=1"
        params = []
        
        if alert_type:
            query += " AND alert_type = ?"
            params.append(alert_type)
        
        if is_major is not None:
            query += " AND is_major = ?"
            params.append(is_major)
            
        if is_active is not None:
            query += " AND is_active = ?"
            params.append(is_active)
        
        query += " ORDER BY id DESC LIMIT ?"
        params.append(limit)
        
        alerts = conn.execute(query, params).fetchall()
        return [dict(alert) for alert in alerts]
        
    except sqlite3.Error as e:
        raise HTTPException(status_code=500, detail=f"Database query failed: {str(e)}")
    finally:
        conn.close()

@app.get("/api/alerts/types")
def get_alert_types():
    """Get all available alert types"""
    conn = get_db_connection()
    
    try:
        types = conn.execute("SELECT DISTINCT alert_type FROM alerts ORDER BY alert_type").fetchall()
        return [t[0] for t in types]
    except sqlite3.Error as e:
        raise HTTPException(status_code=500, detail=f"Database query failed: {str(e)}")
    finally:
        conn.close()

@app.get("/api/alerts/stats")
def get_alert_stats():
    """Get statistics about alerts"""
    conn = get_db_connection()
    
    try:
        # Count by type
        type_counts = conn.execute("""
            SELECT alert_type, COUNT(*) as count 
            FROM alerts 
            WHERE is_active = 1 
            GROUP BY alert_type
        """).fetchall()
        
        # Count major alerts
        major_count = conn.execute("SELECT COUNT(*) FROM alerts WHERE is_major = 1 AND is_active = 1").fetchone()[0]
        
        # Total active alerts
        total_active = conn.execute("SELECT COUNT(*) FROM alerts WHERE is_active = 1").fetchone()[0]
        
        return {
            "total_active": total_active,
            "major_alerts": major_count,
            "by_type": {row[0]: row[1] for row in type_counts}
        }
    except sqlite3.Error as e:
        raise HTTPException(status_code=500, detail=f"Database query failed: {str(e)}")
    finally:
        conn.close()

@app.get("/api/alerts/{alert_id}")
def get_alert_by_id(alert_id: int):
    """Get a specific alert by ID"""
    conn = get_db_connection()
    
    try:
        alert = conn.execute("SELECT * FROM alerts WHERE id = ?", (alert_id,)).fetchone()
        if not alert:
            raise HTTPException(status_code=404, detail="Alert not found")
        return dict(alert)
    except sqlite3.Error as e:
        raise HTTPException(status_code=500, detail=f"Database query failed: {str(e)}")
    finally:
        conn.close()

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)

```

### city-alerts-frontend/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### city-alerts-frontend/src/app/page.tsx

```typescript
"use client"

import { useState, useEffect } from "react"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ScrollArea } from "@/components/ui/scroll-area"
import { AlertTriangle, Car, Building2, Shield, Clock, MapPin, RefreshCw } from "lucide-react"

const API_BASE = 'http://localhost:8000'

interface AlertItem {
  id: number
  title: string
  summary: string
  type?: string
  severity?: string
  timestamp: string
  location?: string
  topics?: string[]
}

interface DatabaseAlert {
  id: number
  timestamp: string
  content: string
  alert_type: string
  is_major: boolean | number
  is_active: boolean | number
}

// Fallback mock data based on actual database structure - for when API is unavailable
const mockData: {
  commuter: AlertItem[],
  city: AlertItem[],
  police: AlertItem[]
} = {
  commuter: [
    {
      id: 2,
      title: "Dumbarton Bridge Wind Advisory",
      summary: "High winds affecting both eastbound and westbound lanes of CA-84 at the Dumbarton Bridge midspan in Fremont. Some lanes are closed. The advisory has been active since June 21, still in effect today.",
      type: "traffic",
      severity: "major",
      timestamp: "2025-06-22T03:07Z",
      location: "CA-84 Dumbarton Bridge",
    },
    {
      id: 1,
      title: "BART Track Replacement",
      summary: "BART will replace decades-old track components between Walnut Creek and Concord stations. As a result, free buses will replace train service between the two stations on June 21–22.",
      type: "transit",
      severity: "minor",
      timestamp: "Jun 21, 22:29",
      location: "Walnut Creek to Concord",
    },
  ],
  city: [],
  police: [
    {
      id: 5,
      title: "Fatal Traffic Collision Investigation",
      summary: "A 79-year-old Fremont resident died several hours after a solo vehicle crash in the 40500 block of Albrae Street, February 24 at approximately 6:40 p.m.",
      type: "police_report",
      severity: "minor",
      timestamp: "February 26, 2025",
      location: "40500 block of Albrae Street",
    },
  ],
}

export default function MyCityPortal() {
  const [activeTab, setActiveTab] = useState("commuter")
  const [data, setData] = useState(mockData)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [isOnline, setIsOnline] = useState(false)

  useEffect(() => {
    fetchAllData()
    // Refresh data every 5 minutes
    const interval = setInterval(fetchAllData, 5 * 60 * 1000)
    return () => clearInterval(interval)
  }, [])

  const fetchAllData = async () => {
    setLoading(true)
    try {
      // Test if backend is available
      const typesResponse = await fetch(`${API_BASE}/api/alerts/types`)
      if (!typesResponse.ok) throw new Error('Backend unavailable')

      setIsOnline(true)

      // Fetch all alert types
      const alertTypes = await typesResponse.json()
      console.log('Available alert types:', alertTypes)

      const newData: {
        commuter: AlertItem[],
        city: AlertItem[],
        police: AlertItem[]
      } = {
        commuter: [],
        city: [],
        police: []
      }

      // Fetch alerts for each type
      for (const type of alertTypes) {
        try {
          const response = await fetch(`${API_BASE}/api/alerts?alert_type=${type}`)
          if (response.ok) {
            const alerts = await response.json()

            // Transform backend data to match frontend structure
            const transformedAlerts = transformAlertsForType(alerts)

            // Categorize alerts
            if (type === 'traffic' || type === 'transit') {
              newData.commuter.push(...transformedAlerts)
            } else if (type === 'city_council') {
              newData.city.push(...transformedAlerts)
            } else if (type === 'police_report') {
              newData.police.push(...transformedAlerts)
            }
          }
        } catch (err) {
          console.error(`Error fetching ${type} alerts:`, err)
        }
      }

      // If we got data from the API, use it
      if (newData.commuter.length > 0 || newData.city.length > 0 || newData.police.length > 0) {
        setData(newData)
        setError(null)
      } else {
        // Fallback to mock data if no real data
        setData(mockData)
        setError('No alerts from API - showing demo data')
      }

    } catch (err) {
      console.error('Error fetching data:', err)
      setIsOnline(false)
      setData(mockData)
      setError('Backend unavailable - showing demo data')
    } finally {
      setLoading(false)
    }
  }

  const transformAlertsForType = (alerts: DatabaseAlert[]): AlertItem[] => {
    if (!Array.isArray(alerts)) {
      // If single alert object, wrap in array
      alerts = [alerts]
    }

    return alerts.map(alert => ({
      id: alert.id,
      title: generateTitleFromContent(alert.content),
      summary: alert.content,
      type: alert.alert_type,
      severity: alert.is_major ? 'major' : 'minor',
      timestamp: formatTimestamp(alert.timestamp),
      location: extractLocationFromContent(alert.content),
      topics: []
    }))
  }

  const generateTitleFromContent = (content: string): string => {
    // For better titles, look for meaningful sentence breaks
    const sentences = content.split('. ')
    
    // Take the first complete sentence
    let title = sentences[0]
    
    // If the first sentence is too short and there's a second one, combine them
    if (title.length < 40 && sentences.length > 1) {
      title = sentences[0] + '. ' + sentences[1]
    }
    
    // Ensure proper sentence ending
    if (!title.endsWith('.') && !title.endsWith('!') && !title.endsWith('?')) {
      title += '.'
    }
    
    // Cap at reasonable length
    if (title.length > 120) {
      title = title.su
[truncated — 11663 more characters]
```

### city-alerts-frontend/next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;

```

### city-alerts-frontend/src/lib/utils.ts

```typescript
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

```

### city-alerts-frontend/src/app/globals.css

```css
@import "tailwindcss";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
  --color-sidebar-ring: var(--sidebar-ring);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar: var(--sidebar);
  --color-chart-5: var(--chart-5);
  --color-chart-4: var(--chart-4);
  --color-chart-3: var(--chart-3);
  --color-chart-2: var(--chart-2);
  --color-chart-1: var(--chart-1);
  --color-ring: var(--ring);
  --color-input: var(--input);
  --color-border: var(--border);
  --color-destructive: var(--destructive);
  --color-accent-foreground: var(--accent-foreground);
  --color-accent: var(--accent);
  --color-muted-foreground: var(--muted-foreground);
  --color-muted: var(--muted);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-secondary: var(--secondary);
  --color-primary-foreground: var(--primary-foreground);
  --color-primary: var(--primary);
  --color-popover-foreground: var(--popover-foreground);
  --color-popover: var(--popover);
  --color-card-foreground: var(--card-foreground);
  --color-card: var(--card);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}

```

### city-alerts-frontend/src/components/ui/badge.tsx

```typescript
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
        destructive:
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Badge({
  className,
  variant,
  asChild = false,
  ...props
}: React.ComponentProps<"span"> &
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : "span"

  return (
    <Comp
      data-slot="badge"
      className={cn(badgeVariants({ variant }), className)}
      {...props}
    />
  )
}

export { Badge, badgeVariants }

```

### city-alerts-frontend/src/components/ui/scroll-area.tsx

```typescript
"use client"

import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"

import { cn } from "@/lib/utils"

function ScrollArea({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
  return (
    <ScrollAreaPrimitive.Root
      data-slot="scroll-area"
      className={cn("relative", className)}
      {...props}
    >
      <ScrollAreaPrimitive.Viewport
        data-slot="scroll-area-viewport"
        className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
      >
        {children}
      </ScrollAreaPrimitive.Viewport>
      <ScrollBar />
      <ScrollAreaPrimitive.Corner />
    </ScrollAreaPrimitive.Root>
  )
}

function ScrollBar({
  className,
  orientation = "vertical",
  ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
  return (
    <ScrollAreaPrimitive.ScrollAreaScrollbar
      data-slot="scroll-area-scrollbar"
      orientation={orientation}
      className={cn(
        "flex touch-none p-px transition-colors select-none",
        orientation === "vertical" &&
          "h-full w-2.5 border-l border-l-transparent",
        orientation === "horizontal" &&
          "h-2.5 flex-col border-t border-t-transparent",
        className
      )}
      {...props}
    >
      <ScrollAreaPrimitive.ScrollAreaThumb
        data-slot="scroll-area-thumb"
        className="bg-border relative flex-1 rounded-full"
      />
    </ScrollAreaPrimitive.ScrollAreaScrollbar>
  )
}

export { ScrollArea, ScrollBar }

```

### city-alerts-frontend/src/components/ui/tabs.tsx

```typescript
"use client"

import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"

import { cn } from "@/lib/utils"

function Tabs({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      className={cn("flex flex-col gap-2", className)}
      {...props}
    />
  )
}

function TabsList({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
  return (
    <TabsPrimitive.List
      data-slot="tabs-list"
      className={cn(
        "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
        className
      )}
      {...props}
    />
  )
}

function TabsTrigger({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
  return (
    <TabsPrimitive.Trigger
      data-slot="tabs-trigger"
      className={cn(
        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function TabsContent({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
  return (
    <TabsPrimitive.Content
      data-slot="tabs-content"
      className={cn("flex-1 outline-none", className)}
      {...props}
    />
  )
}

export { Tabs, TabsList, TabsTrigger, TabsContent }

```

[1 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]