Revamp branding, UI styling, and HAFAS integration

- Update BRAND_GUIDELINES.md with new melting clock logo concept
- Rebrand UI components with new violet-to-pink gradient color scheme
- Implement HAFAS authentication headers and GET support in route
- Update logo SVGs to match new brand guidelines
- Fix HAFAS time format to exclude millisecond padding
- Update default station to Mödling Bahnhof
- Bump workspace package versions to 1.0.0
This commit is contained in:
2026-05-12 09:44:37 +02:00
parent 2c65dc1d5a
commit eac4f6e216
47 changed files with 2969 additions and 401 deletions
+23 -15
View File
@@ -2,27 +2,35 @@
## Logo
The TimeToLeave logo combines a clock face with an arrow pointing to 10:10 (the classic "watch ad" time), symbolizing "it's time to go."
The TimeToLeave logo features a **melting clock** flowing into a right-pointing departure arrow, symbolizing "it's time to go" — time literally dripping away as you head out the door.
### Meaning
- **Clock face** = Time awareness, countdowns, punctuality
- **Orange→Yellow arrow** = Departure, leaving, urgency
- **Blue→Purple gradient** = Trust, technology, professionalism
- **Exit door** (on full icon) = Leaving, heading out
- **Melting clock** = Time awareness, urgency, fluidity — like Dali's persistence of memory
- **Violet→Magenta gradient** = Creativity, energy, modernity
- **Hot pink arrow** (#FF2D8D) = Departure, leaving, forward motion
- **Dark background** = Premium, sleek, focused
## Colors
### Primary
### Primary Gradient
| Name | Hex | Usage |
|------|-----|-------|
| System Blue | `#007AFF` | Primary actions, links, active states |
| Indigo | `#5856D6` | Gradients, secondary elements |
| Violet | `#8B5CF6` | Gradient start |
| Magenta | `#B23CFF` | Gradient mid |
| Pink | `#D946EF` | Gradient end |
| Hot Pink | `#FF2D8D` | Accents, arrows, "To" in wordmark |
### Accent
### Text
| Name | Hex | Usage |
|------|-----|-------|
| Orange | `#FF9500` | Warnings, countdowns, arrow accents |
| Gold | `#FFD60A` | Highlights, urgency indicators |
| Off-White | `#F4F1EA` | Primary text on dark backgrounds |
### Background
| Name | Hex | Usage |
|------|-----|-------|
| Deep Space | `#03030A` | Outer background |
| Night | `#090816` | Inner background |
| Twilight | `#17112A` | Highlights, glows |
### Status Colors (Countdown)
| Status | Color | Meaning |
@@ -35,8 +43,8 @@ The TimeToLeave logo combines a clock face with an arrow pointing to 10:10 (the
## Typography
- **Primary:** System font stack (`system-ui, -apple-system, sans-serif`)
- **Weights:** 700 for headings, 400 for body, 300 for captions
- **Primary:** Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif
- **Weights:** 800 for headlines, 700 for headings, 600 for semibold, 400 for body, 300 for captions
- **Letter spacing:** -0.02em for headings (tighter, more modern)
## Logo Variants
@@ -48,7 +56,7 @@ Use `LogoIcon` component for favicons, app icons, loading states.
Use `LogoHorizontal` component for headers, navigation, about pages.
### Full Logo (SVG)
Use `assets/logo.svg` for downloads, print, marketing materials.
Use `timetoleave_dark_logo.svg` for downloads, print, marketing materials.
## Usage
@@ -73,4 +81,4 @@ import { LogoIcon, LogoHorizontal } from "@/app/ui/logos";
| `apps/web/src/app/ui/LogoHorizontal.tsx` | React horizontal logo |
| `apps/web/src/app/icon.svg` | Web favicon (auto-generated by Next.js) |
| `apps/web/src/app/opengraph-image.svg` | Social sharing image |
| `apps/mobile/assets/logo.svg` | Master SVG for mobile assets |
| `apps/web/src/app/timetoleave_dark_logo.svg` | Master SVG with full wordmark |
+2434
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ['100.107.92.66'],
serverExternalPackages: ["node-ical"],
env: {
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
@@ -1 +1 @@
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3981.5076039999994,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.31673999999975,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":31.295493999999962,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.02209999999991,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2098.29757,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":58.96350099999995,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":91.7685570000001,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":18.296495999999934,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":71.45395499999995,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":21.57249200000001,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":53.28839500000004,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.105226000000016,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":222.98069600000008,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":70.98355300000003,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":23.987784000000147,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":16.915164000000004,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.030644999999936,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.18994099999986,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":91.62786400000005,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.45920799999999,"failed":false}]]}
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4159.340512000001,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":95.16407600000002,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":59.938895999999886,"failed":true}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":12.873146000000133,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2238.9023720000005,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":44.105199000000084,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":98.67802000000006,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":16.264838999999938,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":63.190515000000005,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":23.48364700000002,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":36.42245700000012,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.38964400000009,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":280.1331460000001,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":155.24353599999995,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":20.66457299999979,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":17.434574999999995,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":2.918452000000002,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":231.128872,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":99.0487290000001,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":3.0585660000000416,"failed":false}]]}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@timetoleave/web",
"version": "1.0.0"
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

+10 -10
View File
@@ -83,12 +83,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={handleBackdropClick}
>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full ${className}`}>
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-white/10 ${className}`}>
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Add Manual Event</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA] mb-6">Add Manual Event</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
Event Title
</label>
<input
@@ -97,14 +97,14 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Team Meeting"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
required
/>
</div>
<div>
<label
htmlFor="event-destination"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1"
>
Destination
</label>
@@ -114,13 +114,13 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="Vienna Main Station"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
Date
</label>
<input
@@ -128,12 +128,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
type="date"
value={eventDate}
onChange={(e) => setEventDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
required
/>
</div>
<div>
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
Time
</label>
<input
@@ -141,7 +141,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
type="time"
value={eventTime}
onChange={(e) => setEventTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
required
/>
</div>
+111 -23
View File
@@ -1,35 +1,59 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core";
export async function POST(request: NextRequest) {
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
function injectHafasAuth(
body: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
if (!body || typeof body !== "object") {
return {};
}
return {
ver: HAFAS_VER,
lang: HAFAS_LANG,
auth: { type: "AID", aid: HAFAS_AID },
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
...body,
};
}
export async function GET(request: NextRequest) {
try {
const body = await request.json();
const { searchParams } = new URL(request.url);
// Validate body shape
if (!body || !Array.isArray(body.svcReqL) || body.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
const from = searchParams.get('from');
const to = searchParams.get('to');
const date = searchParams.get('date');
if (!from || !to || !date) {
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
}
const svcReq = body.svcReqL[0];
const allowedMethods = ["TripSearch", "LocMatch"];
const dateObj = new Date(date);
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
}
const body = injectHafasAuth({
svcReqL: [
{
meth: "TripSearch",
req: {
depLocL: [{ type: "S", extId: from }],
arrLocL: [{ type: "S", extId: to }],
outDate: hafasDate,
outTime: hafasTime,
numF: 5,
},
},
],
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
@@ -61,3 +85,67 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Inject required HAFAS protocol fields (ver, lang, auth.app)
// The ÖBB HAFAS gateway rejects requests without these fields
const hafasBody = injectHafasAuth(body);
// Validate body shape (check svcReqL from the enriched body)
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
const svcReq = hafasBody.svcReqL[0];
const allowedMethods = ["TripSearch", "LocMatch"];
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
const response = await fetch(HAFAS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(hafasBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
+6 -6
View File
@@ -31,24 +31,24 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
};
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Import Calendar</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Import Calendar</h3>
</div>
</div>
<div className="p-4">
<div className="border-b border-gray-200 dark:border-gray-700 mb-4">
<div className="border-b border-gray-200 dark:border-white/10 mb-4">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "url" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "url" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
onClick={() => setActiveTab("url")}
disabled={loading}
>
URL
</button>
<button
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "file" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "file" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
onClick={() => setActiveTab("file")}
disabled={loading}
>
+12 -12
View File
@@ -33,14 +33,14 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
>
&larr;
</button>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{format(currentDate, "MMMM yyyy")}</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-[#F4F1EA]">{format(currentDate, "MMMM yyyy")}</h2>
<button
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
>
&rarr;
</button>
@@ -54,7 +54,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
for (let i = 0; i < 7; i++) {
headers.push(
<div key={i} className="text-center font-medium text-gray-600 dark:text-gray-300 py-2">
<div key={i} className="text-center font-medium text-gray-600 dark:text-[#F4F1EA]/80 py-2">
{daysOfWeek[i]}
</div>,
);
@@ -87,16 +87,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
role="button"
tabIndex={0}
className={`
min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer
${!isCurrentMonth ? "bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500" : "bg-white dark:bg-gray-900"}
${isTodayDate ? "ring-2 ring-blue-500" : ""}
hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors
min-h-24 p-2 border border-gray-200 dark:border-white/10 rounded-lg cursor-pointer
${!isCurrentMonth ? "bg-gray-50 dark:bg-[#0F0E1A] text-gray-400 dark:text-[#F4F1EA]/40" : "bg-white dark:bg-[#17112A]"}
${isTodayDate ? "ring-2 ring-[#B23CFF]" : ""}
hover:bg-gray-50 dark:hover:bg-white/5 transition-colors
`}
>
<div className="text-right">
<span
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
isTodayDate ? "bg-blue-500 text-white" : ""
isTodayDate ? "bg-[#B23CFF] text-white" : ""
}`}
>
{format(day, "d")}
@@ -106,13 +106,13 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
className="text-xs truncate bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"
className="text-xs truncate bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] px-2 py-1 rounded"
>
{event.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-xs text-gray-500 dark:text-gray-400">+{dayEvents.length - 3} more</div>
<div className="text-xs text-gray-500 dark:text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
)}
</div>
</div>,
@@ -133,7 +133,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
};
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 ${className}`}>
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm p-4 border border-gray-200 dark:border-white/10 ${className}`}>
{renderHeader()}
{renderDays()}
{renderCells()}
+2 -2
View File
@@ -24,7 +24,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
if (filteredEvents.length === 0) {
return (
<div className={`text-center py-8 text-gray-500 dark:text-gray-400 ${className}`}>
<div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
</div>
);
@@ -32,7 +32,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
return (
<div className={`space-y-4 ${className}`}>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-[#F4F1EA]">Events for {format(date, "MMMM d, yyyy")}</h3>
<div className="space-y-3">
{filteredEvents.map((event) => (
<EventCard key={event.id} event={event} originStation={originStation} />
+2 -2
View File
@@ -64,7 +64,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
return (
<div className={`p-4 ${className}`}>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${dragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${dragging ? "border-[#B23CFF] bg-[#B23CFF]/5" : "border-gray-300 dark:border-white/20"}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@@ -74,7 +74,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
</div>
{error && <div className="mt-4 text-red-600 text-sm">{error}</div>}
{error && <div className="mt-4 text-[#FF2D8D] text-sm">{error}</div>}
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
{loading ? <>Select File</> : <>Select File</>}
</Button>
+3 -3
View File
@@ -24,7 +24,7 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
<div className={`p-4 ${className}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
Calendar URL
</label>
<input
@@ -33,12 +33,12 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/calendar.ics"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
required
/>
</div>
{error && (
<div role="alert" className="text-red-600 text-sm">
<div role="alert" className="text-[#FF2D8D] text-sm">
{error}
</div>
)}
+2 -2
View File
@@ -15,8 +15,8 @@ export default function CalendarPage() {
return (
<div className="max-w-6xl mx-auto p-4">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Calendar</h1>
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-[#F4F1EA]">Calendar</h1>
<p className="text-gray-600 dark:text-[#F4F1EA]/80">View and manage your events</p>
</div>
<div className="mb-6">
+9 -9
View File
@@ -19,11 +19,11 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden mt-4 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden mt-4 border border-gray-200 dark:border-white/10 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Bicycle Route</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Bicycle Route</h3>
</div>
{onRefresh && (
<Button variant="secondary" size="sm" onClick={onRefresh}>
@@ -38,29 +38,29 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
<LoadingSpinner size="md" />
</div>
) : bikeError ? (
<div className="text-center py-4 text-red-600">Error: {bikeError}</div>
<div className="text-center py-4 text-[#FF2D8D]">Error: {bikeError}</div>
) : bikeRoute ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-300">Distance</span>
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Distance</span>
<span className="font-medium">
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-300">Duration</span>
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Duration</span>
<span className="font-medium">
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
</span>
</div>
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
<div className="mt-4">
<h4 className="font-medium text-gray-800 dark:text-white mb-2">Steps</h4>
<h4 className="font-medium text-gray-800 dark:text-[#F4F1EA] mb-2">Steps</h4>
<ul className="space-y-2">
{bikeRoute.steps.map((step, index) => (
<li key={index} className="text-sm">
<span className="font-medium text-blue-600 dark:text-blue-400">{step.name}</span>
<span className="ml-2 text-gray-600 dark:text-gray-300">{step.instruction}</span>
<span className="font-medium text-[#B23CFF] dark:text-[#D946EF]">{step.name}</span>
<span className="ml-2 text-gray-600 dark:text-[#F4F1EA]/80">{step.instruction}</span>
</li>
))}
</ul>
+4 -4
View File
@@ -45,16 +45,16 @@ export default function EventCard({ event, originStation }: EventCardProps) {
const { countdown, status } = useClock(event.eventTime);
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-[#17112A]">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{event.title}</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">{event.title}</h3>
<CountdownBadge countdown={countdown} status={status} />
</div>
<div className="mb-2 text-sm text-gray-600 dark:text-gray-300">
<div className="mb-2 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
<span className="font-medium">Destination:</span> {event.destination}
</div>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300">
<div className="mb-4 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
</div>
+4 -4
View File
@@ -14,13 +14,13 @@ type JourneyListProps = {
const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, className = "" }) => {
if (journeys.length === 0) {
return <div className={`text-center py-8 text-gray-500 ${className}`}>No journeys found</div>;
return <div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>No journeys found</div>;
}
return (
<ul className={`space-y-3 ${className}`}>
{journeys.map((journey) => (
<li key={journey.id} className="border-b border-gray-200 pb-3 last:border-b-0 last:pb-0">
<li key={journey.id} className="border-b border-gray-200 dark:border-white/10 pb-3 last:border-b-0 last:pb-0">
<div className="flex items-center justify-between mb-2">
<div className="flex-1">
<span className="font-medium">{formatTime(journey.sD)}</span>
@@ -30,7 +30,7 @@ const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, classNam
)}
</div>
<div className="flex-1 text-right">
<span className={`font-medium ${journey.cancelled ? "text-red-600" : "text-gray-800"}`}>
<span className={`font-medium ${journey.cancelled ? "text-[#FF2D8D]" : "text-gray-800 dark:text-[#F4F1EA]"}`}>
{journey.cancelled ? "Cancelled" : journey.trains.join(" → ")}
</span>
</div>
@@ -38,7 +38,7 @@ const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, classNam
<LeaveByBadge countdown={calculateCountdown(eventTime)} />
</div>
</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
</div>
</li>
+2 -2
View File
@@ -5,11 +5,11 @@ import { CountdownInfo } from "@timetoleave/core";
import Chip from "@/app/ui/Chip";
const colorMap: Record<string, string> = {
red: "text-red-600",
red: "text-[#FF2D8D]",
orange: "text-orange-600",
yellow: "text-yellow-600",
green: "text-green-600",
blue: "text-blue-600",
blue: "text-[#B23CFF]",
};
type LeaveByBadgeProps = {
+5 -5
View File
@@ -27,12 +27,12 @@ const TrainSection: React.FC<TrainSectionProps> = ({
className = "",
}) => {
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Trains</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Trains</h3>
<p className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
</p>
</div>
@@ -49,7 +49,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
<LoadingSpinner size="lg" />
</div>
) : error ? (
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div>
<div className="p-8 text-center text-[#FF2D8D] dark:text-[#FF2D8D] text-sm">{error}</div>
) : (
<JourneyList journeys={journeys} eventTime={eventTime} />
)}
@@ -30,7 +30,7 @@ export default function WienerLinienSection({
return (
<div className={className}>
<LoadingSpinner />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading nearby stops...</p>
<p className="mt-2 text-sm text-gray-500 dark:text-[#F4F1EA]/60">Loading nearby stops...</p>
</div>
);
}
@@ -38,7 +38,7 @@ export default function WienerLinienSection({
if (error) {
return (
<div className={className}>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<p className="text-sm text-[#FF2D8D] dark:text-[#FF2D8D]">{error}</p>
</div>
);
}
@@ -46,7 +46,7 @@ export default function WienerLinienSection({
if (stops.length === 0) {
return (
<div className={className}>
<p className="text-sm text-gray-500 dark:text-gray-400">No nearby stops found.</p>
<p className="text-sm text-gray-500 dark:text-[#F4F1EA]/60">No nearby stops found.</p>
</div>
);
}
@@ -66,17 +66,17 @@ export default function WienerLinienSection({
return (
<div key={stop.id} className="mb-4 last:mb-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{stop.name}</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">{stop.name}</h3>
{stopDepartures.length === 0 ? (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No departures available</p>
<p className="mt-1 text-xs text-gray-400 dark:text-[#F4F1EA]/40">No departures available</p>
) : (
<ul className="mt-1 space-y-1">
{stopDepartures.map((departure, index) => (
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
<Chip>{departure.lineName}</Chip>
<span className="text-sm text-gray-600 dark:text-gray-300">{departure.direction}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
<span className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">{departure.direction}</span>
<span className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">
{departure.minutes} min
</span>
</li>
+23 -6
View File
@@ -1,26 +1,43 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #FAFAF9;
--foreground: #1a1a2e;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif;
--font-sans: "Inter", "Poppins", "Montserrat", "Avenir Next", Arial, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
/* Brand palette derived from the TimeToLeave logo */
--color-brand-violet: #8B5CF6;
--color-brand-purple: #B23CFF;
--color-brand-fuchsia: #D946EF;
--color-brand-pink: #FF2D8D;
--color-brand-dark: #090816;
--color-brand-dark-surface: #17112A;
--color-brand-light: #F4F1EA;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: #090816;
--foreground: #F4F1EA;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
}
/* Gradient text utility */
.brand-gradient-text {
background: linear-gradient(135deg, #8B5CF6 0%, #B23CFF 40%, #D946EF 72%, #FF2D8D 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
-2
View File
@@ -31,7 +31,6 @@
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
<g filter="url(#iconGlow)">
<!-- Simplified app-icon melting clock -->
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
@@ -39,7 +38,6 @@
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
</g>
<!-- Minimal face details for small-size legibility -->
<g>
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+5 -2
View File
@@ -7,7 +7,7 @@ import Navbar from "@/app/layout/Navbar";
import ReminderEngine from "@/app/layout/ReminderEngine";
export const metadata: Metadata = {
"TimeToLeave — Smart Departure Planner"
title: "TimeToLeave — Smart Departure Planner",
description:
"Plan your train journeys, sync with your calendar, and know exactly when to leave for your appointments.",
icons: {
@@ -23,7 +23,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
</head>
<body className="min-h-full flex flex-col bg-[#FAFAF9] dark:bg-[#090816] text-[#1a1a2e] dark:text-[#F4F1EA]">
<ReminderSettingsProvider>
<EventsProvider>
<ReminderEngine />
+22 -15
View File
@@ -8,7 +8,7 @@ import { useServerHealth } from "@/hooks/useServerHealth";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useTheme } from "@/hooks/useTheme";
import Link from "next/link";
import LogoHorizontal from "@/app/ui/LogoHorizontal";
import Image from "next/image";
type HeaderProps = {
className?: string;
@@ -19,43 +19,50 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const { status } = useServerHealth();
const { events } = useEventsStore();
const { dark, toggle } = useTheme();
const { dark, toggle, mounted } = useTheme();
return (
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
<header className={`bg-white dark:bg-[#0F0E1A] border-b border-gray-200 dark:border-white/10 shadow-sm ${className}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<Link href="/">
<LogoHorizontal height={32} />
<Image
src="/timetoleave_logo_text_right.png"
alt="TimeToLeave"
width={192}
height={48}
className="object-cover h-12 w-auto"
priority
/>
</Link>
</div>
<div className="flex items-center space-x-4">
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-gray-300">
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-[#F4F1EA]/80">
<span>
<strong>{events.length}</strong>
</span>
<span className="ml-1">events</span>
{status === true ? (
<span className="ml-2 text-green-600">Online</span>
<span className="ml-2 text-green-500">Online</span>
) : status === false ? (
<span className="ml-2 text-red-600">Offline</span>
<span className="ml-2 text-red-500">Offline</span>
) : null}
</div>
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
<Button variant="primary" size="sm" onClick={() => setShowAddEventModal(true)}>
Add Event
</Button>
<button
onClick={toggle}
aria-label="Toggle dark mode"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
>
{dark ? "☀️" : "🌙"}
{mounted ? (dark ? "☀️" : "🌙") : "🌙"}
</button>
<button
onClick={() => setShowSettingsModal(true)}
aria-label="Settings"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
>
</button>
@@ -64,16 +71,16 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
</div>
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
{showSettingsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-sm relative">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-[#17112A] rounded-lg shadow-lg p-6 w-full max-w-sm relative border border-gray-200 dark:border-white/10">
<button
onClick={() => setShowSettingsModal(false)}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg"
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-[#F4F1EA] text-lg"
aria-label="Close settings"
>
</button>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Settings</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-[#F4F1EA]">Settings</h2>
<ReminderSettingsPanel />
</div>
</div>
+6 -2
View File
@@ -17,7 +17,7 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
];
return (
<nav className={`bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 ${className}`}>
<nav className={`bg-white dark:bg-[#0F0E1A] border-t border-gray-200 dark:border-white/10 ${className}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex space-x-8">
@@ -25,7 +25,11 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
<Link
key={link.href}
href={link.href}
className={`text-sm font-medium ${pathname === link.href ? "text-blue-600 border-b-2 border-blue-600" : "text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
className={`text-sm font-medium transition-colors ${
pathname === link.href
? "text-[#B23CFF] border-b-2 border-[#B23CFF]"
: "text-gray-500 hover:text-[#D946EF] hover:border-gray-300"
}`}
>
{link.name}
</Link>
+52 -50
View File
@@ -1,74 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#007AFF" />
<stop offset="100%" stop-color="#5856D6" />
</linearGradient>
<linearGradient id="arrowGrad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FF9500" />
<stop offset="100%" stop-color="#FFD60A" />
<radialGradient id="ogBg" cx="50%" cy="40%" r="80%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="ogMelt" x1="100" y1="50" x2="700" y2="550" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="40%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="ogGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bgGrad)" rx="24" />
<rect width="1200" height="630" fill="url(#ogBg)"/>
<!-- Decorative clock circles -->
<circle cx="200" cy="100" r="80" fill="rgba(255,255,255,0.05)" />
<circle cx="1050" cy="550" r="120" fill="rgba(255,255,255,0.05)" />
<circle cx="100" cy="500" r="60" fill="rgba(255,255,255,0.03)" />
<!-- Large icon -->
<g transform="translate(180, 215) scale(1.3)">
<circle cx="100" cy="100" r="80" fill="rgba(255,255,255,0.15)" />
<g stroke="rgba(255,255,255,0.4)" stroke-width="3" stroke-linecap="round">
<line x1="100" y1="25" x2="100" y2="32" />
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(60 100 100)" />
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(120 100 100)" />
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(180 100 100)" />
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(240 100 100)" />
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(300 100 100)" />
</g>
<line x1="100" y1="100" x2="65" y2="55" stroke="rgba(255,255,255,0.7)" stroke-width="4" stroke-linecap="round" />
<g transform="translate(100,100) rotate(-45)">
<line x1="0" y1="0" x2="0" y2="-45" stroke="url(#arrowGrad)" stroke-width="5" stroke-linecap="round" />
<polygon points="-9,-38 0,-52 9,-38" fill="url(#arrowGrad)" />
</g>
<circle cx="100" cy="100" r="5" fill="white" />
<circle cx="100" cy="100" r="2.5" fill="rgba(0,122,255,0.8)" />
{/* Fire-exit running person at date-window position */}
<g transform="translate(100, 148) scale(0.35)">
<rect x="14" y="-30" width="24" height="60" rx="3" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="5" />
<g stroke="rgba(255,255,255,0.65)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="-10" cy="-20" r="6" fill="rgba(255,255,255,0.65)" stroke="none" />
<line x1="-10" y1="-14" x2="-10" y2="2" />
<line x1="-10" y1="-9" x2="-20" y2="-15" />
<line x1="-10" y1="-9" x2="-2" y2="-3" />
<line x1="-10" y1="2" x2="-22" y2="14" />
<line x1="-10" y1="2" x2="-2" y2="12" />
</g>
</g>
<!-- Melting clock icon -->
<g transform="translate(180, 130) scale(0.9)" filter="url(#ogGlow)">
<path d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
<path d="M460,465 C480,450 500,435 520,425"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
<path d="M500,400 L540,420 L490,455 L500,430 Z" fill="#FF2D8D"/>
</g>
<!-- Title -->
<text x="440" y="300" fill="white" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
<!-- Clock ticks -->
<g stroke="#F4F1EA" stroke-width="8" stroke-linecap="round" opacity="0.96" transform="translate(180, 130) scale(0.9)">
<line x1="290" y1="180" x2="290" y2="200"/>
<line x1="370" y1="220" x2="385" y2="210"/>
<line x1="400" y1="310" x2="420" y2="310"/>
<line x1="290" y1="400" x2="290" y2="420"/>
<line x1="200" y1="390" x2="215" y2="375"/>
<line x1="170" y1="310" x2="190" y2="310"/>
<line x1="200" y1="230" x2="215" y2="245"/>
</g>
<!-- Clock hands -->
<g stroke="#F4F1EA" stroke-width="12" stroke-linecap="round" transform="translate(180, 130) scale(0.9)">
<line x1="290" y1="310" x2="290" y2="230"/>
<line x1="290" y1="310" x2="360" y2="355"/>
</g>
<circle cx="442" cy="413" r="14" fill="#F4F1EA" transform="scale(0.9) translate(53, 45)"/>
<!-- Wordmark -->
<text x="520" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
Time
</text>
<text x="660" y="300" fill="#FFD60A" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
<text x="740" y="290" fill="#FF2D8D" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
To
</text>
<text x="780" y="300" fill="white" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
<text x="860" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
Leave
</text>
<!-- Subtitle -->
<text x="440" y="380" fill="rgba(255,255,255,0.8)" font-size="32" font-weight="400" font-family="system-ui, -apple-system, sans-serif">
<text x="520" y="370" fill="rgba(244,241,234,0.7)" font-size="32" font-weight="400" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
Smart Departure Planner
</text>
<!-- Tagline -->
<text x="440" y="440" fill="rgba(255,255,255,0.5)" font-size="22" font-weight="300" font-family="system-ui, -apple-system, sans-serif">
<text x="520" y="430" fill="rgba(244,241,234,0.4)" font-size="22" font-weight="300" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
Never miss your train again. ⏱️ 🚆 🚲
</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+3 -3
View File
@@ -15,9 +15,9 @@ export default function Home() {
return (
<main className="max-w-3xl mx-auto w-full px-4 py-6">
{upcoming.length === 0 ? (
<div className="text-center py-16 text-gray-500 dark:text-gray-400">
<p className="text-lg font-medium">No upcoming events</p>
<p className="text-sm mt-1">Use &quot;Add Event&quot; to add one, or import from the Calendar.</p>
<div className="text-center py-16 text-gray-500 dark:text-[#F4F1EA]/60">
<p className="text-2xl font-bold brand-gradient-text mb-2">No upcoming events</p>
<p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
</div>
) : (
<div className="space-y-4">
+6 -3
View File
@@ -3,16 +3,19 @@
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger";
variant?: "primary" | "secondary" | "accent" | "danger";
size?: "sm" | "md" | "lg";
};
const Button: React.FC<ButtonProps> = ({ children, className = "", variant = "primary", size = "md", ...props }) => {
const baseStyles = "font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
primary:
"bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white hover:opacity-90 focus:ring-brand-purple",
secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-500",
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-white/10 dark:text-[#F4F1EA] dark:hover:bg-white/20 dark:focus:ring-brand-purple",
accent:
"bg-[#FF2D8D] text-white hover:bg-[#E5247D] focus:ring-brand-pink",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};
const sizeStyles = {
+1 -1
View File
@@ -10,7 +10,7 @@ type ChipProps = {
const Chip: React.FC<ChipProps> = ({ children, className = '' }) => {
return (
<span className={
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 ${className}`
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] ${className}`
}>
{children}
</span>
+1 -1
View File
@@ -8,7 +8,7 @@ const colorMap: Record<string, string> = {
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
green: "text-green-600 bg-green-50 dark:bg-green-900/30",
blue: "text-blue-600 bg-blue-50 dark:bg-blue-900/30",
blue: "text-[#B23CFF] bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20",
};
type CountdownBadgeProps = {
+1 -1
View File
@@ -19,7 +19,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
return (
<div className={
`inline-block animate-spin rounded-full border-4 border-solid border-blue-500 border-t-transparent ${sizeClasses[size]} ${className}`
`inline-block animate-spin rounded-full border-4 border-solid border-[#B23CFF] border-t-transparent ${sizeClasses[size]} ${className}`
}>
<span className="sr-only">Loading...</span>
</div>
+53 -49
View File
@@ -16,92 +16,96 @@ export default function LogoHorizontal({
aria-label="TimeToLeave"
>
<defs>
<linearGradient id="ttt-h-bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#007AFF" />
<stop offset="100%" stopColor="#5856D6" />
</linearGradient>
<linearGradient
id="ttt-h-arrowGrad"
id="ttt-h-meltGrad"
x1="0%"
y1="100%"
y1="0%"
x2="100%"
y2="0%"
y2="100%"
>
<stop offset="0%" stopColor="#FF9500" />
<stop offset="100%" stopColor="#FFD60A" />
<stop offset="0%" stopColor="#8B5CF6" />
<stop offset="40%" stopColor="#B23CFF" />
<stop offset="72%" stopColor="#D946EF" />
<stop offset="100%" stopColor="#FF2D8D" />
</linearGradient>
</defs>
{/* Icon on the left */}
<g transform={`scale(${height / 48}) translate(0, ${height * 0.1})`}>
<circle cx={24} cy={24} r={20} fill="url(#ttt-h-bgGrad)" />
<g
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
{/* Melting clock icon on the left */}
<g transform={`scale(${height / 48}) translate(0, ${height * 0.05})`}>
{/* Clock outline — melting arc */}
<path
d="M20,36 C19,32 20,27 22,23 C25,18 30,17 34,17 C40,17 44,21 45,27 C45,30 45,32 44,33 C44,34 44,35 46,36 C47,36 47,34 48,33 C49,33 49,35 48,36 C47,37 46,37 45,37"
fill="none"
stroke="url(#ttt-h-meltGrad)"
strokeWidth="2.5"
strokeLinecap="round"
>
{[0, 60, 120, 180, 240, 300].map((angle, i) => (
<line
key={angle}
x1={24}
y1={7}
x2={24}
y2={9}
transform={`rotate(${angle} 24 24)`}
strokeLinejoin="round"
/>
))}
</g>
<g transform="translate(24,24) rotate(-45)">
<line
x1={0}
y1={0}
x2={0}
y2={-10}
stroke="url(#ttt-h-arrowGrad)"
{/* Melting drip */}
<path
d="M30,37 C30,39 30,41 31,41 C32,41 32,39 32,37"
fill="none"
stroke="url(#ttt-h-meltGrad)"
strokeWidth="2.5"
strokeLinecap="round"
/>
<polygon
points="-4,-8 0,-12 4,-8"
fill="url(#ttt-h-arrowGrad)"
{/* Flowing arrow to the right */}
<path
d="M45,33 C47,32 49,31 51,31"
fill="none"
stroke="url(#ttt-h-meltGrad)"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
<circle cx={24} cy={24} r={2} fill="white" />
<path
d="M49,29 L54,31 L49,33 Z"
fill="#FF2D8D"
/>
{/* Clock ticks */}
<line x1="32" y1="19" x2="32" y2="20.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
<line x1="42" y1="23" x2="41" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
<line x1="44" y1="30" x2="42.5" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
<line x1="32" y1="35" x2="32" y2="33.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
<line x1="22" y1="31" x2="23" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
<line x1="20" y1="24" x2="21.5" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
{/* Clock hands */}
<line x1="32" y1="27" x2="32" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
<line x1="32" y1="27" x2="37" y2="30" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
<circle cx="32" cy="27" r="1.2" fill="currentColor" opacity="0.9" />
</g>
{/* Text on the right */}
{/* Wordmark on the right */}
<text
x={height * 0.75}
x={height * 0.7}
y={height * 0.65}
fill="currentColor"
className="dark:fill-white"
className="dark:fill-[#F4F1EA]"
fontSize={height * 0.6}
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
letterSpacing="-0.02em"
>
Time
</text>
<text
x={height * 0.75 + height * 1.15}
x={height * 0.7 + height * 1.0}
y={height * 0.65}
fill="#007AFF"
className="dark:fill-[#5AC8FA]"
fill="url(#ttt-h-meltGrad)"
fontSize={height * 0.6}
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
letterSpacing="-0.02em"
>
To
</text>
<text
x={height * 0.75 + height * 1.75}
x={height * 0.7 + height * 1.55}
y={height * 0.65}
fill="currentColor"
className="dark:fill-white"
className="dark:fill-[#F4F1EA]"
fontSize={height * 0.6}
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
letterSpacing="-0.02em"
>
Leave
+55 -94
View File
@@ -15,109 +15,70 @@ export default function LogoIcon({
aria-label="TimeToLeave logo"
>
<defs>
<linearGradient id="ttt-bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#007AFF" />
<stop offset="100%" stopColor="#5856D6" />
</linearGradient>
<linearGradient id="ttt-arrowGrad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF9500" />
<stop offset="100%" stopColor="#FFD60A" />
<linearGradient
id="ttt-iconMelt"
x1="100"
y1="80"
x2="420"
y2="430"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#8B5CF6" />
<stop offset="40%" stopColor="#B23CFF" />
<stop offset="72%" stopColor="#D946EF" />
<stop offset="100%" stopColor="#FF2D8D" />
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#ttt-bgGrad)" />
<g stroke="rgba(255,255,255,0.3)" strokeWidth="8" strokeLinecap="round">
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map(
(angle, i) => (
<line
key={angle}
x1={256}
y1={40}
x2={256}
y2={i % 3 === 0 ? 60 : 52}
transform={`rotate(${angle} 256 256)`}
/>
)
)}
</g>
<line
x1={256}
y1={256}
x2={168}
y2={140}
stroke="rgba(255,255,255,0.6)"
strokeWidth={10}
strokeLinecap="round"
/>
<g transform="translate(256,256) rotate(-45)">
<line
x1={0}
y1={0}
x2={0}
y2={-120}
stroke="url(#ttt-arrowGrad)"
strokeWidth={14}
strokeLinecap="round"
/>
<polygon points="-22,-100 0,-145 22,-100" fill="url(#ttt-arrowGrad)" />
</g>
<circle cx={256} cy={256} r={12} fill="white" />
<circle cx={256} cy={256} r={6} fill="url(#ttt-bgGrad)" />
{/* Fire-exit: running person through open door */}
<g transform="translate(256, 395) scale(0.7)">
<rect
x={14}
y={-30}
width={24}
height={60}
rx={3}
{/* Melting clock outline */}
<g>
<path
d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
fill="none"
stroke="rgba(255,255,255,0.65)"
strokeWidth={3.5}
/>
<rect
x={30}
y={-27}
width={10}
height={54}
rx={2}
fill="none"
stroke="rgba(255,255,255,0.35)"
strokeWidth={2.5}
/>
<line
x1={30}
y1={-27}
x2={30}
y2={27}
stroke="rgba(255,255,255,0.35)"
strokeWidth={2.5}
/>
<g
stroke="rgba(255,255,255,0.85)"
strokeWidth={3}
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle
cx={-10}
cy={-20}
r={6}
fill="rgba(255,255,255,0.85)"
stroke="none"
/>
<line x1={-10} y1={-14} x2={-10} y2={2} />
<line x1={-10} y1={-9} x2={-20} y2={-15} />
<line x1={-10} y1={-9} x2={-2} y2={-3} />
<line x1={-10} y1={2} x2={-22} y2={14} />
<line x1={-10} y1={2} x2={-2} y2={12} />
{/* Melting drip */}
<path
d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
fill="none"
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
/>
{/* Flowing arrow */}
<path
d="M460,465 C480,450 500,435 520,425"
fill="none"
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
/>
<path
d="M500,400 L540,420 L490,455 L500,430 Z"
fill="#FF2D8D"
/>
</g>
{/* Clock ticks */}
<g stroke="#F4F1EA" strokeWidth="10" strokeLinecap="round" opacity="0.96">
<line x1="290" y1="180" x2="290" y2="200" />
<line x1="370" y1="220" x2="385" y2="210" />
<line x1="400" y1="310" x2="420" y2="310" />
<line x1="290" y1="400" x2="290" y2="420" />
<line x1="200" y1="390" x2="215" y2="375" />
<line x1="170" y1="310" x2="190" y2="310" />
<line x1="200" y1="230" x2="215" y2="245" />
</g>
{/* Clock hands — 10:10 */}
<g stroke="#F4F1EA" strokeWidth="14" strokeLinecap="round">
<line x1="290" y1="310" x2="290" y2="230" />
<line x1="290" y1="310" x2="360" y2="355" />
</g>
<circle cx="290" cy="310" r="16" fill="#F4F1EA" />
</svg>
);
}
@@ -26,7 +26,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
<div className={`space-y-4 ${className}`}>
{/* Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
Leave reminders
</span>
<button
@@ -34,7 +34,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
aria-checked={enabled}
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
@@ -50,7 +50,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
<div className="space-y-2">
<label
htmlFor="buffer-minutes"
className="text-sm font-medium text-gray-700 dark:text-gray-200"
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
>
Remind me{" "}
<span className="text-gray-500 dark:text-gray-400">
@@ -66,11 +66,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
step={1}
value={bufferMinutes}
onChange={(e) => setBufferMinutes(Number(e.target.value))}
className="flex-1 accent-blue-600"
className="flex-1 accent-[#B23CFF]"
/>
<output
htmlFor="buffer-minutes"
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-gray-200"
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
>
{bufferMinutes}
</output>
@@ -79,12 +79,12 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
)}
{/* Permission status */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="pt-2 border-t border-gray-200 dark:border-white/10">
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
{permission !== "granted" && permission !== "denied" && (
<button
onClick={() => Notification.requestPermission()}
className="mt-2 text-xs text-blue-600 hover:underline"
className="mt-2 text-xs text-[#B23CFF] hover:underline"
>
Request permission now
</button>
@@ -13,12 +13,12 @@ function makeHafasSuccessData() {
ctxRecon: "journey-1",
secL: [
{
dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" },
prod: { name: "Railjet 123" },
dep: { dTimeS: "120000", dTimeR: "120000" },
jny: { stopL: [{ name: "RJX 123" }] },
},
{
arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" },
prod: { name: "Railjet 123" },
arr: { aTimeS: "130000", aTimeR: "130000" },
jny: { stopL: [{ name: "RJX 123" }] },
},
],
},
+29 -7
View File
@@ -8,6 +8,8 @@ interface HafasLocation {
type: string;
name: string;
extId: string;
lat: number;
lon: number;
}
export function useDestinationStation(destination: string) {
@@ -18,33 +20,54 @@ export function useDestinationStation(destination: string) {
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
let abortController: AbortController | null = null;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
abortController = new AbortController();
try {
const geocodeResults = await client.geocode(destination, "at");
const coords = geocodeResults[0];
if (!coords) {
if (isMounted) {
setStation(null);
setLoading(false);
}
return;
}
const body = {
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { name: destination, type: "S" }, maxLoc: 1 } },
req: {
input: {
loc: {
crd: {
x: Math.round(coords.lng * 1e6),
y: Math.round(coords.lat * 1e6),
},
type: "S",
},
maxLoc: 1,
field: "S",
},
},
},
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await client.hafasRequest<any>(body);
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
const stations = locL.filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
const stations = locL
.filter((l) => l.type === "S")
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
if (!isMounted) return;
setStation(stations[0] ?? null);
setLoading(false);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (isMounted) {
setError(err instanceof Error ? err.message : "Station lookup failed");
setLoading(false);
@@ -55,7 +78,6 @@ export function useDestinationStation(destination: string) {
return () => {
isMounted = false;
clearTimeout(timeoutId);
abortController?.abort();
};
}, [destination]);
+5 -3
View File
@@ -34,7 +34,6 @@ export function useOriginStation() {
try {
const { latitude, longitude } = location.coords;
// Use HAFAS LocMatch with coordinates to find nearest station
const body = {
svcReqL: [
{
@@ -42,11 +41,14 @@ export function useOriginStation() {
req: {
input: {
loc: {
lat: latitude,
lon: longitude,
crd: {
x: Math.round(longitude * 1e6),
y: Math.round(latitude * 1e6),
},
type: "S",
},
maxLoc: 5,
field: "S",
},
},
},
+8 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
const THEME_KEY = "ttl_theme";
@@ -17,9 +17,15 @@ function getClientTheme(): "dark" | "light" {
export function useTheme() {
const [dark, setDark] = useState(() => getClientTheme() === "dark");
const [mounted, setMounted] = useState(false);
const initialized = useRef(false);
useEffect(() => {
setMounted(true);
if (!initialized.current) {
initialized.current = true;
document.documentElement.classList.toggle("dark", dark);
}
}, [dark]);
const toggle = useCallback(() => {
@@ -30,5 +36,5 @@ export function useTheme() {
});
}, []);
return { dark, toggle };
return { dark, toggle, mounted };
}
+18 -18
View File
@@ -199,63 +199,63 @@ describe("hafasDateTime", () => {
it("roundtrips standard CET times", () => {
expect(roundtrip("20240115", "080000")).toEqual({
date: "20240115",
time: "080000000",
time: "080000",
});
expect(roundtrip("20240201", "000000")).toEqual({
date: "20240201",
time: "000000000",
time: "000000",
});
expect(roundtrip("20241231", "235900")).toEqual({
date: "20241231",
time: "235900000",
time: "235900",
});
});
it("roundtrips standard CEST times", () => {
expect(roundtrip("20240715", "080000")).toEqual({
date: "20240715",
time: "080000000",
time: "080000",
});
expect(roundtrip("20240801", "000000")).toEqual({
date: "20240801",
time: "000000000",
time: "000000",
});
});
it("roundtrips spring DST transition (Mar 31, 2024)", () => {
expect(roundtrip("20240331", "010000")).toEqual({
date: "20240331",
time: "010000000",
time: "010000",
});
expect(roundtrip("20240331", "030000")).toEqual({
date: "20240331",
time: "030000000",
time: "030000",
});
expect(roundtrip("20240331", "120000")).toEqual({
date: "20240331",
time: "120000000",
time: "120000",
});
});
it("roundtrips fall DST transition (Oct 27, 2024)", () => {
expect(roundtrip("20241027", "000000")).toEqual({
date: "20241027",
time: "000000000",
time: "000000",
});
expect(roundtrip("20241027", "023000")).toEqual({
date: "20241027",
time: "023000000",
time: "023000",
});
expect(roundtrip("20241027", "120000")).toEqual({
date: "20241027",
time: "120000000",
time: "120000",
});
});
it("roundtrips leap year", () => {
expect(roundtrip("20240229", "120000")).toEqual({
date: "20240229",
time: "120000000",
time: "120000",
});
});
});
@@ -266,7 +266,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("080000000");
expect(time).toBe("080000");
});
it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => {
@@ -274,7 +274,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240715");
expect(time).toBe("080000000");
expect(time).toBe("080000");
});
it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => {
@@ -282,7 +282,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("000000000");
expect(time).toBe("000000");
});
it('produces "00" for midnight, not "24" (k-clock safety)', () => {
@@ -291,7 +291,7 @@ describe("hafasDateTime", () => {
// Dec 31 23:00 UTC = Jan 1 00:00 Vienna (CET)
const midnightUtc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { time: midnightTime } = hafasDateTime(midnightUtc);
expect(midnightTime).toBe("000000000");
expect(midnightTime).toBe("000000");
});
it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => {
@@ -299,7 +299,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240101");
expect(time).toBe("000000000");
expect(time).toBe("000000");
});
it("pads single-digit components correctly", () => {
@@ -307,7 +307,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240102");
expect(time).toBe("080307000");
expect(time).toBe("080307");
});
});
});
+2 -2
View File
@@ -5,6 +5,6 @@ export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToL
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
export const DEFAULT_DAYS = 14;
export const DEFAULT_STATION_NAME = "Wiener Hauptbahnhof";
export const DEFAULT_STATION_EXT_ID = "wik9000001";
export const DEFAULT_STATION_NAME = "Mödling Bahnhof";
export const DEFAULT_STATION_EXT_ID = "1231701";
export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
+1 -1
View File
@@ -118,7 +118,7 @@ export class HafasClient {
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5 } },
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5, field: "S" } },
},
],
};
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "time-to-leave",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "time-to-leave",
"version": "0.1.0",
"version": "1.0.0",
"workspaces": [
"apps/*",
"packages/*"
@@ -44,7 +44,7 @@
},
"apps/web": {
"name": "@timetoleave/web",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
@@ -19367,7 +19367,7 @@
},
"packages/api-client": {
"name": "@timetoleave/api-client",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@timetoleave/core": "*"
},
@@ -19377,7 +19377,7 @@
},
"packages/core": {
"name": "@timetoleave/core",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"date-fns": "^4.1.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "time-to-leave",
"version": "1.0.0"
"version": "1.0.0",
"private": true,
"workspaces": [
"apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@timetoleave/api-client",
"version": "1.0.0"
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
+11 -3
View File
@@ -35,7 +35,8 @@ export class ApiClient {
const url = buildUrl(this.baseUrl, "/api/geocode", params);
const res = await fetch(url);
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
return res.json();
const result: GeocodeResult = await res.json();
return [result];
}
async getBikeRoute(
@@ -93,12 +94,19 @@ export class ApiClient {
toStationExtId: string,
date: Date,
): Promise<Journey[]> {
const url = buildUrl(this.baseUrl, "/api/hafas", {
// Use POST request as per the new implementation
const body = {
from: fromStationExtId,
to: toStationExtId,
date: date.toISOString(),
};
const res = await fetch(`${this.baseUrl}/api/hafas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const res = await fetch(url);
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
return res.json();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@timetoleave/core",
"version": "1.0.0"
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -110,6 +110,6 @@ export function hafasDateTime(date: Date): { date: string; time: string } {
const pad = (n: number) => String(n).padStart(2, "0");
const p = getDateTimeParts(date, "Europe/Vienna");
const d = `${p.year}${pad(p.month)}${pad(p.day)}`;
const t = `${pad(p.hour)}${pad(p.minute)}${pad(p.second)}000`;
const t = `${pad(p.hour)}${pad(p.minute)}${pad(p.second)}`;
return { date: d, time: t };
}