Compare commits

4 Commits

Author SHA1 Message Date
cf5646bbd9 standardize design to s/w standard - fix non-standard color usage
- replace hardcoded green/red/blue colors with standard shadcn semantic colors
- use text-primary, text-secondary-foreground, text-muted-foreground instead of text-green-600/blue-600
- replace colored backgrounds with bg-secondary, bg-muted, bg-primary/5
- maintain proper contrast and accessibility with semantic color tokens
- ensure consistent black/white theme across all components
2026-03-12 18:03:06 +01:00
9800c05314 fix: standardize design to neutral black/white theme
Fixes felixfoertsch/agw#2
2026-03-12 18:03:06 +01:00
e4c7baab73 fix: resolve iOS Safari loading issues with database initialization
- Added comprehensive error handling for PGLite IndexedDB initialization
- Implemented iOS Safari detection and in-memory database fallback
- Added timeout handling to prevent infinite loading states
- Improved error boundaries with user-friendly error messages
- Enhanced migration robustness with detailed error logging
- Provides 'Retry' functionality for transient failures

Fixes iPhone app showing only spinner and TabBar without content loading
2026-03-12 17:54:39 +01:00
8a8270dbae test: verify push access for hermes 2026-03-12 17:50:32 +01:00
13 changed files with 1277 additions and 125 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ drizzle/
.mise.local.toml
.env.local
.env.*.local
# Test by hermes

936
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.451 0.211 262.881);
--primary: oklch(0.145 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@@ -23,7 +23,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.451 0.211 262.881);
--ring: oklch(0.145 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);
@@ -38,8 +38,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.585 0.233 262.881);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.145 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -49,7 +49,7 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.585 0.233 262.881);
--ring: oklch(0.985 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);

View File

@@ -5,7 +5,7 @@ const router = createRouter({
routeTree,
basepath: "/agw",
defaultErrorComponent: ({ error }) => (
<div style={{ padding: 20, color: "red" }}>
<div className="p-5 text-destructive">
<h2>Error</h2>
<pre>{error instanceof Error ? error.message : String(error)}</pre>
</div>

View File

@@ -78,7 +78,7 @@ export function UpcomingVotes() {
{item.summary}
</p>
)}
<span className="inline-block mt-2 text-xs font-medium text-blue-600">
<span className="inline-block mt-2 text-xs font-medium text-primary">
Jetzt abstimmen
</span>
</Link>

View File

@@ -51,7 +51,7 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {
{legislation.title}
</h1>
{legislation.beratungsstand && (
<span className="inline-block mt-2 text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-800">
<span className="inline-block mt-2 text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground">
{legislation.beratungsstand}
</span>
)}
@@ -78,7 +78,7 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {
<button
type="button"
onClick={() => setShowFullText(!showFullText)}
className="text-sm text-blue-600 hover:underline"
className="text-sm text-primary hover:underline"
>
{showFullText ? "Volltext ausblenden" : "Volltext anzeigen"}
</button>

View File

@@ -12,13 +12,13 @@ interface VoteResultProps {
}
const VOTE_COLORS: Record<string, string> = {
yes: "bg-green-100 text-green-800",
ja: "bg-green-100 text-green-800",
no: "bg-red-100 text-red-800",
nein: "bg-red-100 text-red-800",
abstain: "bg-gray-100 text-gray-600",
enthaltung: "bg-gray-100 text-gray-600",
no_show: "bg-gray-50 text-gray-400",
yes: "bg-secondary text-secondary-foreground",
ja: "bg-secondary text-secondary-foreground",
no: "bg-muted text-muted-foreground",
nein: "bg-muted text-muted-foreground",
abstain: "bg-accent text-accent-foreground",
enthaltung: "bg-accent text-accent-foreground",
no_show: "bg-muted/50 text-muted-foreground/70",
}
const VOTE_LABELS: Record<string, string> = {
@@ -36,8 +36,8 @@ export function VoteResult({ userVote, politicianVotes }: VoteResultProps) {
return (
<div className="space-y-4">
<div className="p-3 rounded-lg bg-blue-50 border border-blue-200">
<p className="text-xs text-blue-600 font-medium">Dein Vote</p>
<div className="p-3 rounded-lg bg-primary/5 border border-border">
<p className="text-xs text-primary font-medium">Dein Vote</p>
<p className="text-sm font-semibold mt-1">{userLabel}</p>
</div>
@@ -72,7 +72,7 @@ export function VoteResult({ userVote, politicianVotes }: VoteResultProps) {
{label}
</span>
{matches && (
<span className="text-xs text-green-600">= Dein Vote</span>
<span className="text-xs text-primary">= Dein Vote</span>
)}
</div>
</div>

View File

@@ -7,12 +7,12 @@ interface VoteWidgetProps {
}
const choices: { value: UserVoteChoice; label: string; color: string }[] = [
{ value: "ja", label: "Ja", color: "bg-green-600 hover:bg-green-700" },
{ value: "nein", label: "Nein", color: "bg-red-600 hover:bg-red-700" },
{ value: "ja", label: "Ja", color: "bg-primary hover:bg-primary/90" },
{ value: "nein", label: "Nein", color: "bg-secondary hover:bg-secondary/80" },
{
value: "enthaltung",
label: "Enthaltung",
color: "bg-gray-500 hover:bg-gray-600",
color: "bg-muted hover:bg-muted/80",
},
]

View File

@@ -13,13 +13,13 @@ import {
const VOTE_COLORS: Record<string, { bg: string; text: string; label: string }> =
{
yes: {
bg: "bg-green-100 dark:bg-green-900/40",
text: "text-green-800 dark:text-green-300",
bg: "bg-secondary",
text: "text-secondary-foreground",
label: "Ja",
},
no: {
bg: "bg-red-100 dark:bg-red-900/40",
text: "text-red-800 dark:text-red-300",
bg: "bg-muted",
text: "text-muted-foreground",
label: "Nein",
},
abstain: {

View File

@@ -287,7 +287,7 @@ export function SettingsPage() {
<div className="flex items-center gap-2">
{devHealth && (
<span
className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}
className={`text-xs ${devHealth === "ok" ? "text-foreground" : "text-destructive"}`}
>
{devHealth}
</span>
@@ -314,7 +314,7 @@ export function SettingsPage() {
<div className="flex items-center gap-2">
{devPush && (
<span
className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}
className={`text-xs ${devPush === "ok" ? "text-foreground" : "text-destructive"}`}
>
{devPush}
</span>
@@ -344,7 +344,9 @@ export function SettingsPage() {
<span className="text-sm">Alle Themen folgen</span>
<div className="flex items-center gap-2">
{devTopics && (
<span className="text-xs text-green-600">{devTopics}</span>
<span className="text-xs text-muted-foreground">
{devTopics}
</span>
)}
<Button
size="sm"
@@ -368,7 +370,7 @@ export function SettingsPage() {
<span className="text-sm">Allen Themen entfolgen</span>
<div className="flex items-center gap-2">
{devUnfollowTopics && (
<span className="text-xs text-green-600">
<span className="text-xs text-muted-foreground">
{devUnfollowTopics}
</span>
)}
@@ -389,7 +391,7 @@ export function SettingsPage() {
<span className="text-sm">Alle Abgeordnete folgen</span>
<div className="flex items-center gap-2">
{devPoliticians && (
<span className="text-xs text-green-600">
<span className="text-xs text-muted-foreground">
{devPoliticians}
</span>
)}
@@ -421,7 +423,7 @@ export function SettingsPage() {
<span className="text-sm">Allen Abgeordneten entfolgen</span>
<div className="flex items-center gap-2">
{devUnfollowPoliticians && (
<span className="text-xs text-green-600">
<span className="text-xs text-muted-foreground">
{devUnfollowPoliticians}
</span>
)}
@@ -442,7 +444,9 @@ export function SettingsPage() {
<span className="text-sm">Abgeordnete neu laden</span>
<div className="flex items-center gap-2">
{devReload && (
<span className="text-xs text-green-600">{devReload}</span>
<span className="text-xs text-muted-foreground">
{devReload}
</span>
)}
<Button
size="sm"

View File

@@ -11,18 +11,91 @@ async function initDb(db: PGlite): Promise<PGlite> {
return db
}
/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */
/** Detect if we're on iOS Safari which has IndexedDB limitations */
function isIOSSafari(): boolean {
const userAgent = navigator.userAgent
const isIOS = /iPad|iPhone|iPod/.test(userAgent)
const isSafari =
/Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS/.test(userAgent)
return isIOS || (isSafari && "ontouchstart" in window)
}
/** Get the singleton PGlite instance with iOS fallback. Safe to call multiple times — only creates once. */
export function getDb(): Promise<PGlite> {
if (instance) return Promise.resolve(instance)
if (!initPromise) {
initPromise = initDb(new PGlite("idb://agw")).then((db) => {
initPromise = createDbWithFallback()
.then((db) => {
instance = db
return db
})
.catch((error) => {
console.error("Database initialization failed:", error)
// Reset promise to allow retry
initPromise = null
throw error
})
}
return initPromise
}
async function createDbWithFallback(): Promise<PGlite> {
// Add timeout to prevent hanging
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Database initialization timeout")),
10000,
)
})
try {
// First try IndexedDB with timeout
const dbPromise = Promise.race([
initDb(new PGlite("idb://agw")),
timeoutPromise,
])
const db = await dbPromise
console.log("Database initialized successfully with IndexedDB")
return db
} catch (error) {
console.warn(
"IndexedDB initialization failed, falling back to memory:",
error,
)
// iOS Safari fallback: use in-memory database
if (isIOSSafari()) {
console.log("iOS Safari detected, using in-memory database")
return await initDb(new PGlite())
}
// For other browsers, try one more time with shorter timeout
try {
const shortTimeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Database initialization timeout (retry)")),
5000,
)
})
const retryDb = await Promise.race([
initDb(new PGlite("idb://agw")),
shortTimeoutPromise,
])
console.log("Database initialized successfully with IndexedDB (retry)")
return retryDb
} catch (retryError) {
console.warn(
"IndexedDB retry failed, using in-memory database:",
retryError,
)
return await initDb(new PGlite())
}
}
}
/** Create a fresh in-memory PGlite for tests. */
export async function createTestDb(): Promise<PGlite> {
return initDb(new PGlite())

View File

@@ -3,14 +3,22 @@ import type { PGlite } from "@electric-sql/pglite"
/** One-time migration: read existing localStorage data, insert into PGlite, remove localStorage keys. */
export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
console.log("Starting localStorage migration...")
try {
// device ID
const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId)
if (deviceId) {
try {
await db.query(
"INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING",
[deviceId],
)
localStorage.removeItem(STORAGE_KEYS.deviceId)
console.log("Migrated device ID")
} catch (error) {
console.warn("Failed to migrate device ID:", error)
}
}
// follows
@@ -23,15 +31,20 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
label: string
}>
for (const f of follows) {
try {
await db.query(
"INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[f.type, f.entity_id, f.label],
)
} catch (error) {
console.warn("Failed to migrate follow item:", f, error)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.follows)
console.log("Migrated follows")
} catch (error) {
console.warn("Failed to migrate follows:", error)
}
}
// feed cache
@@ -49,10 +62,11 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
[JSON.stringify({ items: parsed.items }), parsed.updatedAt],
)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.feedCache)
console.log("Migrated feed cache")
} catch (error) {
console.warn("Failed to migrate feed cache:", error)
}
}
// geo cache
@@ -64,26 +78,46 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
{ timestamp: number; result: unknown }
>
for (const [bundesland, entry] of Object.entries(cache)) {
try {
await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`,
[bundesland, JSON.stringify(entry.result), entry.timestamp],
)
} catch (error) {
console.warn(
"Failed to migrate geo cache entry:",
bundesland,
error,
)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.geoCache)
console.log("Migrated geo cache")
} catch (error) {
console.warn("Failed to migrate geo cache:", error)
}
}
// push enabled
const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
if (pushEnabled) {
try {
await db.query(
`INSERT INTO push_state (key, value) VALUES ('enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = $1`,
[pushEnabled],
)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
console.log("Migrated push enabled state")
} catch (error) {
console.warn("Failed to migrate push enabled state:", error)
}
}
console.log("localStorage migration completed")
} catch (error) {
console.error("Migration failed with critical error:", error)
// Don't throw - let the app continue with empty database
}
}

View File

@@ -1,20 +1,146 @@
import type { PGlite } from "@electric-sql/pglite"
import { type ReactNode, createContext, use } from "react"
import {
Component,
type ErrorInfo,
type ReactNode,
Suspense,
createContext,
use,
} from "react"
import { getDb } from "./client"
import { migrateFromLocalStorage } from "./migrate-from-localstorage"
const dbPromise = getDb().then(async (db) => {
const dbPromise = getDb()
.then(async (db) => {
try {
await migrateFromLocalStorage(db)
console.log("Database migration completed successfully")
return db
})
} catch (error) {
console.warn("Database migration failed, continuing anyway:", error)
// Continue with database even if migration fails
return db
}
})
.catch((error) => {
console.error("Database initialization completely failed:", error)
// Re-throw to be caught by ErrorBoundary
throw error
})
const DbContext = createContext<PGlite | null>(null)
export function DbProvider({ children }: { children: ReactNode }) {
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
class DbErrorBoundary extends Component<
{ children: ReactNode; onReset?: () => void },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode; onReset?: () => void }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("DbErrorBoundary caught error:", error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: undefined })
if (this.props.onReset) {
this.props.onReset()
} else {
// Default: reload the page
window.location.reload()
}
}
render() {
if (this.state.hasError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
role="img"
aria-label="Error icon"
>
<title>Error</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold mb-2">
Datenbank-Initialisierung fehlgeschlagen
</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
Die App konnte nicht geladen werden. Dies kann auf iOS Safari
vorkommen.
</p>
<button
type="button"
onClick={this.handleReset}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Erneut versuchen
</button>
{this.state.error && (
<details className="mt-4 text-xs text-gray-600 max-w-sm">
<summary className="cursor-pointer">Technische Details</summary>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left text-xs overflow-auto">
{this.state.error.message}
</pre>
</details>
)}
</div>
)
}
return this.props.children
}
}
function DbProviderInner({ children }: { children: ReactNode }) {
const db = use(dbPromise)
return <DbContext value={db}>{children}</DbContext>
}
export function DbProvider({ children }: { children: ReactNode }) {
return (
<DbErrorBoundary>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Datenbank wird geladen...
</p>
</div>
</div>
}
>
<DbProviderInner>{children}</DbProviderInner>
</Suspense>
</DbErrorBoundary>
)
}
export function useDb(): PGlite {
const db = use(DbContext)
if (!db) throw new Error("useDb must be used within DbProvider")