Switch mobile app to dark theme and fix calendar filtering

Switch mobile app to dark theme and fix calendar filtering

- Default to dark theme in mobile app, matching web app style
- Filter native calendar events by location to exclude empty ones
- Add batch edit panel for destinations on web calendar
- Add edit support to AddEventModal
- Update notification trigger input type export
This commit is contained in:
2026-05-13 09:45:55 +02:00
parent de9a8606ab
commit 9e461aab1a
22 changed files with 632 additions and 168 deletions
+73 -47
View File
@@ -1,92 +1,108 @@
"use client";
import React, { useState, useEffect } from "react";
import { format } from "date-fns";
import Button from "@/app/ui/Button";
import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event } from "@timetoleave/core";
type AddEventModalProps = {
isOpen: boolean;
onClose: () => void;
editEvent?: Event;
className?: string;
};
const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, className = "" }) => {
const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEvent, className = "" }) => {
const isEditing = !!editEvent;
const [title, setTitle] = useState("");
const [destination, setDestination] = useState("");
const [eventTime, setEventTime] = useState("");
const [eventDate, setEventDate] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const { addEvent } = useEventsStore();
const { addEvent, updateEvent } = useEventsStore();
// Populate fields when opening in edit mode
useEffect(() => {
if (editEvent) {
setTitle(editEvent.title);
setDestination(editEvent.destination);
setEventDate(format(editEvent.eventTime, "yyyy-MM-dd"));
setEventTime(format(editEvent.eventTime, "HH:mm"));
} else {
setTitle("");
setDestination("");
setEventDate("");
setEventTime("");
}
setSuccess(false);
}, [editEvent, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) {
return;
}
if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) return;
setLoading(true);
try {
const eventTimeDate = new Date(`${eventDate} ${eventTime}`);
const eventTimeDate = new Date(`${eventDate}T${eventTime}`);
await addEvent({
id: `manual-${Date.now()}`,
title: title.trim(),
destination: destination.trim(),
eventTime: eventTimeDate,
source: "manual",
});
if (isEditing && editEvent) {
updateEvent(editEvent.id, {
title: title.trim(),
destination: destination.trim(),
eventTime: eventTimeDate,
});
} else {
await addEvent({
id: `manual-${Date.now()}`,
title: title.trim(),
destination: destination.trim(),
eventTime: eventTimeDate,
source: "manual",
});
}
// Reset form and close modal
setTitle("");
setDestination("");
setEventTime("");
setEventDate("");
onClose();
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
}, 1200);
} catch (err) {
console.error("Error adding event:", err);
console.error("Error saving event:", err);
} finally {
setLoading(false);
}
};
// Close modal when clicking outside
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
if (e.target === e.currentTarget) onClose();
};
// Close modal with Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
if (e.key === "Escape") onClose();
};
if (isOpen) document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}>
<div className={`brand-panel w-full max-w-md rounded-2xl relative ${className}`}>
<div className="p-6">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p>
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">
{isEditing ? "Edit event" : "Manual event"}
</p>
<h3 className="mb-6 text-2xl font-bold text-white">
{isEditing ? "Edit departure target" : "Add a departure target"}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
@@ -103,10 +119,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
/>
</div>
<div>
<label
htmlFor="event-destination"
className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
>
<label htmlFor="event-destination" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
Destination
</label>
<input
@@ -152,11 +165,24 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? <>Add Event</> : <>Add Event</>}
{isEditing ? "Save Changes" : "Add Event"}
</Button>
</div>
</form>
</div>
{success && (
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-[#090816]/90 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-[#34C759] to-[#30d158] text-3xl shadow-lg">
</div>
<p className="text-lg font-semibold text-white">
{isEditing ? "Changes saved" : "Event added"}
</p>
</div>
</div>
)}
</div>
</div>
);
@@ -0,0 +1,158 @@
"use client";
import React, { useState, useMemo } from "react";
import { format } from "date-fns";
import { useEventsStore } from "@/hooks/useEventsStore";
import Button from "@/app/ui/Button";
function sourceLabel(source: string): string {
if (source === "manual") return "Manually added";
if (source === "google_calendar") return "Google Calendar";
if (source.startsWith("calendar:")) {
const url = source.slice("calendar:".length);
try {
return new URL(url).hostname;
} catch {
return url.slice(0, 40);
}
}
if (source.startsWith("native:")) return `Device calendar (${source.slice(7)})`;
return source;
}
export default function BatchEditPanel() {
const { events, updateEvent } = useEventsStore();
const [sourceFilter, setSourceFilter] = useState<string>("__all__");
const [destContains, setDestContains] = useState("");
const [newDestination, setNewDestination] = useState("");
const [applied, setApplied] = useState<number | null>(null);
const sources = useMemo(() => {
const seen = new Set<string>();
for (const e of events) seen.add(e.source);
return Array.from(seen).sort();
}, [events]);
const matched = useMemo(() => {
return events.filter((e) => {
const sourceMatch = sourceFilter === "__all__" || e.source === sourceFilter;
const destMatch = destContains.trim() === "" ||
e.destination.toLowerCase().includes(destContains.trim().toLowerCase());
return sourceMatch && destMatch;
});
}, [events, sourceFilter, destContains]);
const handleApply = () => {
if (!newDestination.trim() || matched.length === 0) return;
for (const e of matched) {
updateEvent(e.id, { destination: newDestination.trim() });
}
setApplied(matched.length);
setNewDestination("");
setTimeout(() => setApplied(null), 3000);
};
if (events.length === 0) return null;
return (
<div className="brand-panel overflow-hidden rounded-2xl">
<div className="border-b border-white/10 p-4">
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Bulk action</p>
<h3 className="text-lg font-semibold text-white">Batch-edit destinations</h3>
<p className="mt-1 text-sm text-[#F4F1EA]/55">
Replace the destination on multiple events at once useful when a calendar stores a room number but you need a full address.
</p>
</div>
<div className="p-4 space-y-4">
{/* Filters */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label htmlFor="batch-source" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Calendar source
</label>
<select
id="batch-source"
value={sourceFilter}
onChange={(e) => { setSourceFilter(e.target.value); setApplied(null); }}
className="brand-input px-3 py-2 text-sm"
>
<option value="__all__">All sources ({events.length} events)</option>
{sources.map((s) => (
<option key={s} value={s}>
{sourceLabel(s)} ({events.filter((e) => e.source === s).length})
</option>
))}
</select>
</div>
<div>
<label htmlFor="batch-dest-filter" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Current destination contains
</label>
<input
id="batch-dest-filter"
type="text"
value={destContains}
onChange={(e) => { setDestContains(e.target.value); setApplied(null); }}
placeholder="e.g. HS, Seminarraum, Room …"
className="brand-input px-3 py-2 text-sm"
/>
</div>
</div>
{/* Preview */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Matched events ({matched.length})
</p>
{matched.length === 0 ? (
<p className="text-sm text-[#F4F1EA]/40">No events match the current filters.</p>
) : (
<ul className="max-h-48 overflow-y-auto space-y-1 rounded-xl border border-white/10 bg-black/20 p-2">
{matched.map((e) => (
<li key={e.id} className="flex items-baseline justify-between gap-3 rounded-lg px-2 py-1.5 text-sm hover:bg-white/[0.04]">
<span className="font-medium text-white truncate">{e.title}</span>
<span className="shrink-0 text-[#F4F1EA]/46 text-xs">
{e.destination} · {format(e.eventTime, "dd MMM")}
</span>
</li>
))}
</ul>
)}
</div>
{/* Replace with */}
<div className="flex gap-3 items-end">
<div className="flex-1">
<label htmlFor="batch-new-dest" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Replace destination with
</label>
<input
id="batch-new-dest"
type="text"
value={newDestination}
onChange={(e) => { setNewDestination(e.target.value); setApplied(null); }}
placeholder="e.g. Universitätsplatz 3, 8010 Graz"
className="brand-input px-3 py-2 text-sm"
/>
</div>
<Button
onClick={handleApply}
disabled={!newDestination.trim() || matched.length === 0}
className="shrink-0"
>
Apply to {matched.length} event{matched.length === 1 ? "" : "s"}
</Button>
</div>
{applied !== null && (
<p className="text-sm font-medium text-emerald-400">
Updated destination on {applied} event{applied === 1 ? "" : "s"}.
</p>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { useOriginStation } from "@/hooks/useOriginStation";
import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel";
import BatchEditPanel from "./BatchEditPanel";
export default function CalendarPage() {
const { events } = useEventsStore();
@@ -20,8 +21,9 @@ export default function CalendarPage() {
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
</div>
<div className="mb-6">
<div className="mb-6 space-y-4">
<CalendarPanel />
<BatchEditPanel />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+31 -1
View File
@@ -11,11 +11,13 @@ import { useClock } from "@/hooks/useClock";
import { useDepartureTime } from "@/hooks/useDepartureTime";
import { useReminderSettings } from "@/hooks/useReminderSettings";
import { useWienerLinien } from "@/hooks/useWienerLinien";
import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event, Station } from "@timetoleave/core";
import TrainSection from "./TrainSection";
import BikeSection from "./BikeSection";
import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge";
import AddEventModal from "@/app/add-event/AddEventModal";
interface EventCardProps {
event: Event;
@@ -23,6 +25,15 @@ interface EventCardProps {
}
export default function EventCard({ event, originStation }: EventCardProps) {
const [editOpen, setEditOpen] = useState(false);
const { removeEvent } = useEventsStore();
const handleRemove = () => {
if (window.confirm(`Remove "${event.title}"?`)) {
removeEvent(event.id);
}
};
const destStation = useDestinationStation(event.destination);
const {
@@ -83,13 +94,30 @@ export default function EventCard({ event, originStation }: EventCardProps) {
];
return (
<>
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p>
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
</div>
<CountdownBadge countdown={countdown} status={status} />
<div className="flex items-start gap-2">
<CountdownBadge countdown={countdown} status={status} />
<button
onClick={() => setEditOpen(true)}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-brand-fuchsia/40 hover:text-white"
aria-label="Edit event"
>
Edit
</button>
<button
onClick={handleRemove}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-red-500/50 hover:text-red-400"
aria-label="Remove event"
>
Remove
</button>
</div>
</div>
<div className="mb-5 grid gap-3 sm:grid-cols-2">
@@ -178,5 +206,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
)}
</div>
</div>
<AddEventModal isOpen={editOpen} onClose={() => setEditOpen(false)} editEvent={event} />
</>
);
}
@@ -95,6 +95,18 @@ vi.mock("@/hooks/useClock", () => ({
}),
}));
vi.mock("@/hooks/useEventsStore", () => ({
useEventsStore: () => ({
events: [],
addEvent: vi.fn(),
updateEvent: vi.fn(),
removeEvent: vi.fn(),
clearEvents: vi.fn(),
setEvents: vi.fn(),
mergeEvents: vi.fn(),
}),
}));
vi.mock("@/lib/countdown-utils", () => ({
calculateCountdown: () => ({
label: "No deadline set",