Refactor monorepo structure and replace hardcoded colors
Add TypeScript project references, update ESLint configs to support ESM modules, and introduce brand semantic color tokens across the web app. Add comprehensive documentation for architecture, API reference, development setup, and user guide.
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import ts from "typescript-eslint";
|
import ts from "typescript-eslint";
|
||||||
import reactPlugin from "eslint-plugin-react";
|
import reactPlugin from "eslint-plugin-react";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// We have no .eslintrc, so we must define everything here.
|
// We have no .eslintrc, so we must define everything here.
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ export default ts.config(
|
|||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
jsx: true,
|
jsx: true,
|
||||||
},
|
},
|
||||||
|
tsconfigRootDir: path.resolve(__dirname),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
@@ -14,6 +18,11 @@ const eslintConfig = defineConfig([
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: path.resolve(__dirname),
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Allow _-prefixed parameters and variables to signal intentionally unused.
|
// Allow _-prefixed parameters and variables to signal intentionally unused.
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export default function CalendarPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
|
<div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
|
||||||
<div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl">
|
<div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl">
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-[#D946EF]">Calendar sync</p>
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Calendar sync</p>
|
||||||
<h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1>
|
<h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1>
|
||||||
<p className="mt-2 text-[#F4F1EA]/66">View and manage every appointment from a single departure-focused calendar.</p>
|
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const BikeSection: React.FC<BikeSectionProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
|
<h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
|
||||||
<p className="text-sm text-[#F4F1EA]/60">Door-to-door route to the event</p>
|
<p className="text-sm text-brand-light/60">Door-to-door route to the event</p>
|
||||||
</div>
|
</div>
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||||
@@ -47,29 +47,29 @@ const BikeSection: React.FC<BikeSectionProps> = ({
|
|||||||
<LoadingSpinner size="md" />
|
<LoadingSpinner size="md" />
|
||||||
</div>
|
</div>
|
||||||
) : bikeError ? (
|
) : bikeError ? (
|
||||||
<div className="text-center py-4 text-[#FF2D8D]">Error: {bikeError}</div>
|
<div className="text-center py-4 text-brand-pink">Error: {bikeError}</div>
|
||||||
) : bikeRoute ? (
|
) : bikeRoute ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[#F4F1EA]/66">Distance</span>
|
<span className="text-brand-light/66">Distance</span>
|
||||||
<span className="font-medium text-white">
|
<span className="font-medium text-white">
|
||||||
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[#F4F1EA]/66">Duration</span>
|
<span className="text-brand-light/66">Duration</span>
|
||||||
<span className="font-medium text-white">
|
<span className="font-medium text-white">
|
||||||
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="mb-2 font-medium text-[#F4F1EA]">Steps</h4>
|
<h4 className="mb-2 font-medium text-brand-light">Steps</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{bikeRoute.steps.map((step, index) => (
|
{bikeRoute.steps.map((step, index) => (
|
||||||
<li key={index} className="text-sm">
|
<li key={index} className="text-sm">
|
||||||
<span className="font-medium text-[#D946EF]">{step.name}</span>
|
<span className="font-medium text-brand-fuchsia">{step.name}</span>
|
||||||
<span className="ml-2 text-[#F4F1EA]/70">{step.instruction}</span>
|
<span className="ml-2 text-brand-light/70">{step.instruction}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -77,7 +77,7 @@ const BikeSection: React.FC<BikeSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-4 text-center text-sm text-[#F4F1EA]/58">
|
<div className="py-4 text-center text-sm text-brand-light/58">
|
||||||
Add origin and destination coordinates to calculate a bike route.
|
Add origin and destination coordinates to calculate a bike route.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
|
<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 className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Next stop</p>
|
<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>
|
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<CountdownBadge countdown={countdown} status={status} />
|
<CountdownBadge countdown={countdown} status={status} />
|
||||||
@@ -94,12 +94,12 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
|
|
||||||
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
||||||
<div className="brand-panel-soft rounded-xl p-3">
|
<div className="brand-panel-soft rounded-xl p-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/42">Destination</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Destination</p>
|
||||||
<p className="mt-1 text-sm font-medium text-[#F4F1EA]">{event.destination}</p>
|
<p className="mt-1 text-sm font-medium text-brand-light">{event.destination}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="brand-panel-soft rounded-xl p-3">
|
<div className="brand-panel-soft rounded-xl p-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/42">Appointment</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Appointment</p>
|
||||||
<p className="mt-1 text-sm font-medium text-[#F4F1EA]">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
<p className="mt-1 text-sm font-medium text-brand-light">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
key={option.id}
|
key={option.id}
|
||||||
className={`rounded-xl border p-3 text-left transition-all ${
|
className={`rounded-xl border p-3 text-left transition-all ${
|
||||||
activeMode === option.id
|
activeMode === option.id
|
||||||
? "border-[#D946EF]/70 bg-[#B23CFF]/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
|
? "border-brand-fuchsia/70 bg-brand-purple/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
|
||||||
: "border-white/10 bg-white/[0.045] hover:border-[#D946EF]/40 hover:bg-white/[0.07]"
|
: "border-white/10 bg-white/[0.045] hover:border-brand-fuchsia/40 hover:bg-white/[0.07]"
|
||||||
} ${option.disabled ? "cursor-not-allowed opacity-45 hover:border-white/10 hover:bg-white/[0.045]" : ""}`}
|
} ${option.disabled ? "cursor-not-allowed opacity-45 hover:border-white/10 hover:bg-white/[0.045]" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!option.disabled) {
|
if (!option.disabled) {
|
||||||
@@ -122,31 +122,31 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
<span className="flex items-center justify-between gap-3">
|
<span className="flex items-center justify-between gap-3">
|
||||||
<span className="font-semibold text-white">{option.label}</span>
|
<span className="font-semibold text-white">{option.label}</span>
|
||||||
{activeMode === option.id && (
|
{activeMode === option.id && (
|
||||||
<span className="rounded-full bg-[#FF2D8D]/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
|
<span className="rounded-full bg-brand-pink/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-xs text-[#F4F1EA]/58">{option.meta}</span>
|
<span className="mt-1 block text-xs text-brand-light/58">{option.meta}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 grid gap-3 rounded-xl border border-white/10 bg-black/16 p-3 text-sm sm:grid-cols-3">
|
<div className="mb-4 grid gap-3 rounded-xl border border-white/10 bg-black/16 p-3 text-sm sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Leave by</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Leave by</p>
|
||||||
<p className="mt-1 font-semibold text-white">
|
<p className="mt-1 font-semibold text-white">
|
||||||
{departureTime ? format(departureTime, "HH:mm") : "Pending"}
|
{departureTime ? format(departureTime, "HH:mm") : "Pending"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Arrive by</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Arrive by</p>
|
||||||
<p className="mt-1 font-semibold text-white">
|
<p className="mt-1 font-semibold text-white">
|
||||||
{arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")}
|
{arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Buffer</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Buffer</p>
|
||||||
<p className="mt-1 font-semibold text-white">
|
<p className="mt-1 font-semibold text-white">
|
||||||
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
|
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||||
|
|
||||||
if (journeys.length === 0) {
|
if (journeys.length === 0) {
|
||||||
return <div className={`py-8 text-center text-[#F4F1EA]/60 ${className}`}>No journeys found</div>;
|
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,20 +37,20 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
key={journey.id}
|
key={journey.id}
|
||||||
className={`rounded-xl border p-3 ${
|
className={`rounded-xl border p-3 ${
|
||||||
arrivesTooLate || journey.cancelled
|
arrivesTooLate || journey.cancelled
|
||||||
? "border-[#FF2D8D]/18 bg-[#FF2D8D]/7 opacity-40 line-through"
|
? "border-brand-pink/18 bg-brand-pink/7 opacity-40 line-through"
|
||||||
: "border-white/10 bg-white/[0.04]"
|
: "border-white/10 bg-white/[0.04]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-semibold text-white">{formatTime(departure)}</span>
|
<span className="font-semibold text-white">{formatTime(departure)}</span>
|
||||||
<span className="ml-2 text-sm text-[#F4F1EA]/48">{journey.platform}</span>
|
<span className="ml-2 text-sm text-brand-light/48">{journey.platform}</span>
|
||||||
{journey.delay > 0 && (
|
{journey.delay > 0 && (
|
||||||
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
|
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-right">
|
<div className="flex-1 text-right">
|
||||||
<span className={`font-medium ${journey.cancelled ? "text-[#FF2D8D]" : "text-[#F4F1EA]"}`}>
|
<span className={`font-medium ${journey.cancelled ? "text-brand-pink" : "text-brand-light"}`}>
|
||||||
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")}
|
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,11 +58,11 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
<LeaveByBadge countdown={calculateCountdown(departure)} />
|
<LeaveByBadge countdown={calculateCountdown(departure)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#F4F1EA]/64">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-brand-light/64">
|
||||||
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
||||||
<span>Arrives {formatTime(arrival)}</span>
|
<span>Arrives {formatTime(arrival)}</span>
|
||||||
{arrivesTooLate && (
|
{arrivesTooLate && (
|
||||||
<span className="font-medium text-[#FF2D8D]">
|
<span className="font-medium text-brand-pink">
|
||||||
misses {arrivalBufferMinutes} min buffer
|
misses {arrivalBufferMinutes} min buffer
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Trains</h3>
|
<h3 className="text-lg font-semibold text-white">Trains</h3>
|
||||||
<p className="text-sm text-[#F4F1EA]/66">
|
<p className="text-sm text-brand-light/66">
|
||||||
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||||
</p>
|
</p>
|
||||||
{(arrivalBufferMinutes ?? 0) > 0 && (
|
{(arrivalBufferMinutes ?? 0) > 0 && (
|
||||||
<p className="mt-1 text-xs text-[#D946EF]">
|
<p className="mt-1 text-xs text-brand-fuchsia">
|
||||||
Target arrival: {arrivalBufferMinutes} min early
|
Target arrival: {arrivalBufferMinutes} min early
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +65,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-8 text-center text-sm text-[#FF2D8D]">{error}</div>
|
<div className="p-8 text-center text-sm text-brand-pink">{error}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, w
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Final Walk</h3>
|
<h3 className="text-lg font-semibold text-white">Final Walk</h3>
|
||||||
<p className="text-sm text-[#F4F1EA]/60">From arrival station to destination</p>
|
<p className="text-sm text-brand-light/60">From arrival station to destination</p>
|
||||||
</div>
|
</div>
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||||
@@ -39,29 +39,29 @@ const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, w
|
|||||||
<LoadingSpinner size="md" />
|
<LoadingSpinner size="md" />
|
||||||
</div>
|
</div>
|
||||||
) : walkError ? (
|
) : walkError ? (
|
||||||
<div className="py-4 text-center text-[#FF2D8D]">Error: {walkError}</div>
|
<div className="py-4 text-center text-brand-pink">Error: {walkError}</div>
|
||||||
) : walkRoute ? (
|
) : walkRoute ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[#F4F1EA]/66">Distance</span>
|
<span className="text-brand-light/66">Distance</span>
|
||||||
<span className="font-medium text-white">
|
<span className="font-medium text-white">
|
||||||
{Math.round(walkRoute.distance / 1000)} km ({Math.round(walkRoute.distance)} m)
|
{Math.round(walkRoute.distance / 1000)} km ({Math.round(walkRoute.distance)} m)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[#F4F1EA]/66">Duration</span>
|
<span className="text-brand-light/66">Duration</span>
|
||||||
<span className="font-medium text-white">
|
<span className="font-medium text-white">
|
||||||
{Math.floor(walkRoute.duration / 60)} min {walkRoute.duration % 60} s
|
{Math.floor(walkRoute.duration / 60)} min {walkRoute.duration % 60} s
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{walkRoute.steps && walkRoute.steps.length > 0 && (
|
{walkRoute.steps && walkRoute.steps.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="mb-2 font-medium text-[#F4F1EA]">Steps</h4>
|
<h4 className="mb-2 font-medium text-brand-light">Steps</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
|
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
|
||||||
<li key={index} className="text-sm">
|
<li key={index} className="text-sm">
|
||||||
<span className="font-medium text-[#D946EF]">{step.name}</span>
|
<span className="font-medium text-brand-fuchsia">{step.name}</span>
|
||||||
<span className="ml-2 text-[#F4F1EA]/70">{step.instruction}</span>
|
<span className="ml-2 text-brand-light/70">{step.instruction}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
||||||
<body className="brand-shell min-h-full flex flex-col bg-[#090816] text-[#F4F1EA]">
|
<body className="brand-shell min-h-full flex flex-col bg-[#090816] text-brand-light">
|
||||||
<ReminderSettingsProvider>
|
<ReminderSettingsProvider>
|
||||||
<EventsProvider>
|
<EventsProvider>
|
||||||
<ReminderEngine />
|
<ReminderEngine />
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex h-12 items-center rounded-lg text-[#F4F1EA] drop-shadow-[0_0_18px_rgba(178,60,255,0.28)] focus:outline-none focus:ring-2 focus:ring-[#D946EF]/70"
|
className="flex h-12 items-center rounded-lg text-brand-light drop-shadow-[0_0_18px_rgba(178,60,255,0.28)] focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/70"
|
||||||
aria-label="TimeToLeave home"
|
aria-label="TimeToLeave home"
|
||||||
>
|
>
|
||||||
<LogoHorizontal height={42} className="block h-[42px] w-auto" />
|
<LogoHorizontal height={42} className="block h-[42px] w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="hidden md:flex items-center rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-[#F4F1EA]/76">
|
<div className="hidden md:flex items-center rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-brand-light/76">
|
||||||
<span className="font-semibold text-white">
|
<span className="font-semibold text-white">
|
||||||
{events.length}
|
{events.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -46,8 +46,8 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
Online
|
Online
|
||||||
</span>
|
</span>
|
||||||
) : status === false ? (
|
) : status === false ? (
|
||||||
<span className="ml-3 inline-flex items-center gap-1 text-[#FF2D8D]">
|
<span className="ml-3 inline-flex items-center gap-1 text-brand-pink">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#FF2D8D]" />
|
<span className="h-1.5 w-1.5 rounded-full bg-brand-pink" />
|
||||||
Offline
|
Offline
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -58,7 +58,7 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-[#F4F1EA]/80 transition-colors hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D946EF]/60"
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-brand-light/80 transition-colors hover:border-brand-fuchsia/45 hover:bg-brand-fuchsia/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/60"
|
||||||
>
|
>
|
||||||
{mounted && dark ? (
|
{mounted && dark ? (
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -74,7 +74,7 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSettingsModal(true)}
|
onClick={() => setShowSettingsModal(true)}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-[#F4F1EA]/80 transition-colors hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D946EF]/60"
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-brand-light/80 transition-colors hover:border-brand-fuchsia/45 hover:bg-brand-fuchsia/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/60"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5z" />
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5z" />
|
||||||
@@ -90,12 +90,12 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
<div className="brand-panel relative w-full max-w-sm rounded-2xl p-6">
|
<div className="brand-panel relative w-full max-w-sm rounded-2xl p-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettingsModal(false)}
|
onClick={() => setShowSettingsModal(false)}
|
||||||
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#F4F1EA]/50 transition-colors hover:bg-white/10 hover:text-white"
|
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-lg text-brand-light/50 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
aria-label="Close settings"
|
aria-label="Close settings"
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
<h2 className="mb-4 text-lg font-semibold text-[#F4F1EA]">Settings</h2>
|
<h2 className="mb-4 text-lg font-semibold text-brand-light">Settings</h2>
|
||||||
<ReminderSettingsPanel />
|
<ReminderSettingsPanel />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function Home() {
|
|||||||
<section className="mb-7 overflow-hidden rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-7">
|
<section className="mb-7 overflow-hidden rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-7">
|
||||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-[#D946EF]">Departure desk</p>
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Departure desk</p>
|
||||||
<h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl">
|
<h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl">
|
||||||
Know when to leave before the clock turns hostile.
|
Know when to leave before the clock turns hostile.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -25,22 +25,22 @@ export default function Home() {
|
|||||||
<div className="grid grid-cols-2 gap-3 sm:min-w-60">
|
<div className="grid grid-cols-2 gap-3 sm:min-w-60">
|
||||||
<div className="brand-panel-soft rounded-xl p-3">
|
<div className="brand-panel-soft rounded-xl p-3">
|
||||||
<p className="text-2xl font-bold text-white">{upcoming.length}</p>
|
<p className="text-2xl font-bold text-white">{upcoming.length}</p>
|
||||||
<p className="text-xs text-[#F4F1EA]/60">upcoming</p>
|
<p className="text-xs text-brand-light/60">upcoming</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="brand-panel-soft rounded-xl p-3">
|
<div className="brand-panel-soft rounded-xl p-3">
|
||||||
<p className="text-2xl font-bold text-white">{events.length}</p>
|
<p className="text-2xl font-bold text-white">{events.length}</p>
|
||||||
<p className="text-xs text-[#F4F1EA]/60">total events</p>
|
<p className="text-xs text-brand-light/60">total events</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center">
|
<div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center">
|
||||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-[#FF2D8D] text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]">
|
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-brand-pink text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]">
|
||||||
T
|
T
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-2xl font-bold text-white">No upcoming events</p>
|
<p className="mb-2 text-2xl font-bold text-white">No upcoming events</p>
|
||||||
<p className="mx-auto max-w-sm text-sm leading-6 text-[#F4F1EA]/66">
|
<p className="mx-auto max-w-sm text-sm leading-6 text-brand-light/66">
|
||||||
Add an event or import your calendar to turn this into a live departure board.
|
Add an event or import your calendar to turn this into a live departure board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
`inline-block animate-spin rounded-full border-4 border-solid border-[#B23CFF] border-t-transparent ${sizeClasses[size]} ${className}`
|
`inline-block animate-spin rounded-full border-4 border-solid border-brand-purple border-t-transparent ${sizeClasses[size]} ${className}`
|
||||||
}
|
}
|
||||||
data-testid="loading-spinner"
|
data-testid="loading-spinner"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
<div className={`space-y-4 ${className}`}>
|
<div className={`space-y-4 ${className}`}>
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
<span className="text-sm font-medium text-brand-light/80">
|
||||||
Leave reminders
|
Leave reminders
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -38,7 +38,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
onClick={() => setEnabled(!enabled)}
|
onClick={() => setEnabled(!enabled)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -54,10 +54,10 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="buffer-minutes"
|
htmlFor="buffer-minutes"
|
||||||
className="text-sm font-medium text-[#F4F1EA]/80"
|
className="text-sm font-medium text-brand-light/80"
|
||||||
>
|
>
|
||||||
Remind me{" "}
|
Remind me{" "}
|
||||||
<span className="text-[#F4F1EA]/48">
|
<span className="text-brand-light/48">
|
||||||
(minutes before event)
|
(minutes before event)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -70,11 +70,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
step={1}
|
step={1}
|
||||||
value={bufferMinutes}
|
value={bufferMinutes}
|
||||||
onChange={(e) => setBufferMinutes(Number(e.target.value))}
|
onChange={(e) => setBufferMinutes(Number(e.target.value))}
|
||||||
className="flex-1 accent-[#B23CFF]"
|
className="flex-1 accent-brand-purple"
|
||||||
/>
|
/>
|
||||||
<output
|
<output
|
||||||
htmlFor="buffer-minutes"
|
htmlFor="buffer-minutes"
|
||||||
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-[#F4F1EA]/80"
|
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-brand-light/80"
|
||||||
>
|
>
|
||||||
{bufferMinutes}
|
{bufferMinutes}
|
||||||
</output>
|
</output>
|
||||||
@@ -86,10 +86,10 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="arrival-buffer"
|
htmlFor="arrival-buffer"
|
||||||
className="text-sm font-medium text-[#F4F1EA]/80"
|
className="text-sm font-medium text-brand-light/80"
|
||||||
>
|
>
|
||||||
Arrive early
|
Arrive early
|
||||||
<span className="text-[#F4F1EA]/48">
|
<span className="text-brand-light/48">
|
||||||
(minutes before event)
|
(minutes before event)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -102,11 +102,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
step={1}
|
step={1}
|
||||||
value={arrivalBufferMinutes}
|
value={arrivalBufferMinutes}
|
||||||
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
|
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
|
||||||
className="flex-1 accent-[#B23CFF]"
|
className="flex-1 accent-brand-purple"
|
||||||
/>
|
/>
|
||||||
<output
|
<output
|
||||||
htmlFor="arrival-buffer"
|
htmlFor="arrival-buffer"
|
||||||
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-[#F4F1EA]/80"
|
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-brand-light/80"
|
||||||
>
|
>
|
||||||
{arrivalBufferMinutes}
|
{arrivalBufferMinutes}
|
||||||
</output>
|
</output>
|
||||||
@@ -115,7 +115,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
|
|
||||||
{/* Show walking option toggle */}
|
{/* Show walking option toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
<span className="text-sm font-medium text-brand-light/80">
|
||||||
Show walking option
|
Show walking option
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -123,7 +123,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
aria-checked={showWalkingOption}
|
aria-checked={showWalkingOption}
|
||||||
onClick={() => setShowWalkingOption(!showWalkingOption)}
|
onClick={() => setShowWalkingOption(!showWalkingOption)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -136,7 +136,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
|
|
||||||
{/* Show bike option toggle */}
|
{/* Show bike option toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
<span className="text-sm font-medium text-brand-light/80">
|
||||||
Show bike route
|
Show bike route
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -144,7 +144,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
aria-checked={showBikeOption}
|
aria-checked={showBikeOption}
|
||||||
onClick={() => setShowBikeOption(!showBikeOption)}
|
onClick={() => setShowBikeOption(!showBikeOption)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -157,11 +157,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
|||||||
|
|
||||||
{/* Permission status */}
|
{/* Permission status */}
|
||||||
<div className="border-t border-white/10 pt-2">
|
<div className="border-t border-white/10 pt-2">
|
||||||
<p className="text-xs text-[#F4F1EA]/50">{statusLabel}</p>
|
<p className="text-xs text-brand-light/50">{statusLabel}</p>
|
||||||
{permission !== "granted" && permission !== "denied" && (
|
{permission !== "granted" && permission !== "denied" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => Notification.requestPermission()}
|
onClick={() => Notification.requestPermission()}
|
||||||
className="mt-2 text-xs text-[#B23CFF] hover:underline"
|
className="mt-2 text-xs text-brand-purple hover:underline"
|
||||||
>
|
>
|
||||||
Request permission now
|
Request permission now
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Core & API Client Reference
|
||||||
|
|
||||||
|
This document provides a detailed reference for the internal packages: `@timetoleave/core` and `@timetoleave/api-client`.
|
||||||
|
|
||||||
|
## 📦 `@timetoleave/core`
|
||||||
|
|
||||||
|
The core package contains all domain types, status calculation logic, HAFAS time parsing, and formatting utilities. It has zero runtime dependencies outside of the standard library.
|
||||||
|
|
||||||
|
### Types (`types.ts`)
|
||||||
|
|
||||||
|
| Interface | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `Event` | Represents a calendar event with an assigned destination and event time. |
|
||||||
|
| `CalendarEvent` | Raw calendar event data (typically `string` dates before conversion to `Event`). |
|
||||||
|
| `Station` | A transport station with a name, HAFAS `extId`, and optional coordinates. |
|
||||||
|
| `Journey` | A public transport connection including scheduled/real departure/arrival, delay, platform, and cancellation status. |
|
||||||
|
| `BikeRoute` / `WalkRoute` | Routing data including total distance, duration, and step-by-step instructions. |
|
||||||
|
| `CountdownInfo` | Output of the countdown utility containing a label, color code, and urgency flag. |
|
||||||
|
| `ReminderSettings` | User preferences for buffers, walking/bike options, and reminder toggles. |
|
||||||
|
| `WienerLinienDeparture` | Specific types for WienerLinien (Vienna public transport) monitor responses. |
|
||||||
|
|
||||||
|
### HAFAS Time Utilities (`hafas-time.ts`)
|
||||||
|
|
||||||
|
HAFAS timestamps are strictly tied to the `Europe/Vienna` timezone (CET/CEST). These utilities handle the bi-directional conversion between UTC JavaScript `Date` objects and HAFAS strings, correctly handling Daylight Saving Time (DST) transitions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { parseHafasTime, hafasDateTime } from '@timetoleave/core';
|
||||||
|
|
||||||
|
// Parse HAFAS date ("YYYYMMDD") and time ("HHMMSS") into a UTC Date object
|
||||||
|
const dateObj = parseHafasTime("20231027", "143000");
|
||||||
|
|
||||||
|
// Convert a UTC Date object back to HAFAS date and time strings
|
||||||
|
const { date, time } = hafasDateTime(dateObj);
|
||||||
|
// Returns: { date: "20231027", time: "143000" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `getTimezoneOffsetMinutes` is designed exclusively for full-hour offsets like `Europe/Vienna`. It will produce incorrect results for fractional timezones (e.g., India +05:30).
|
||||||
|
|
||||||
|
### Countdown & Status Utilities
|
||||||
|
|
||||||
|
#### `calculateCountdown(targetDate: Date): CountdownInfo`
|
||||||
|
Calculates the time delta between `now` and `targetDate`, returning a human-readable label, a color code, and an urgency boolean.
|
||||||
|
|
||||||
|
| Time Delta | Label | Color | Urgent |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `<= 0 min` | `Now` | `red` | `true` |
|
||||||
|
| `<= 10 min` | `[X]min` | `orange` | `true` |
|
||||||
|
| `<= 30 min` | `[X]min` | `yellow` | `false` |
|
||||||
|
| `<= 60 min` | `[X]min` | `green` | `false` |
|
||||||
|
| `> 60 min` | `[X]h [Y]min` | `blue` | `false` |
|
||||||
|
|
||||||
|
#### `getLeaveStatus(event: Event, journeys: Journey[]): string`
|
||||||
|
Derives a human-readable leave-by status by finding the earliest non-cancelled journey and comparing its real departure time (`rD`) against the current time.
|
||||||
|
|
||||||
|
**Possible Returns:**
|
||||||
|
- `"No journey data"`
|
||||||
|
- `"All journeys cancelled"`
|
||||||
|
- `"Departure missed"` (if `rD` is in the past)
|
||||||
|
- `"Delayed +[X] min"` (if delay exceeds 10 minutes)
|
||||||
|
- `"Leave now"` (if departure is within 15 minutes)
|
||||||
|
- `"On time"`
|
||||||
|
|
||||||
|
### Formatting Utilities (`formatting.ts`)
|
||||||
|
|
||||||
|
All formatting functions default to the `de-AT` locale to match the primary target region.
|
||||||
|
|
||||||
|
- `formatTime(date: Date)` -> `"14:30"`
|
||||||
|
- `formatDate(date: Date)` -> `"Mi., 27. Oktober 2023"`
|
||||||
|
- `formatDateTime(date: Date)` -> `"27. Oktober 2023, 14:30"`
|
||||||
|
- `formatDuration(seconds: number)` -> `"1h 23min"` or `"45min"`
|
||||||
|
- `formatDistance(meters: number)` -> `"1.2km"` or `"800m"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 `@timetoleave/api-client`
|
||||||
|
|
||||||
|
The API client is a lightweight wrapper around the Web App's Next.js API routes. It handles URL construction, query parameters, and JSON serialization.
|
||||||
|
|
||||||
|
### Initialization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiClient } from '@timetoleave/api-client';
|
||||||
|
|
||||||
|
// Initialize with the base URL of the backend proxy
|
||||||
|
const api = new ApiClient('http://localhost:3000');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
#### `getHealth()`
|
||||||
|
```typescript
|
||||||
|
getHealth(): Promise<{ status: 'ok'; uptime: number }>
|
||||||
|
```
|
||||||
|
Checks the `/api/health` endpoint to verify backend availability.
|
||||||
|
|
||||||
|
#### `geocode(name: string, countrycodes?: string)`
|
||||||
|
```typescript
|
||||||
|
geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]>
|
||||||
|
```
|
||||||
|
Performs forward geocoding via `/api/geocode`.
|
||||||
|
|
||||||
|
#### `reverseGeocode(lat: number, lng: number)`
|
||||||
|
```typescript
|
||||||
|
reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null>
|
||||||
|
```
|
||||||
|
Performs reverse geocoding via `/api/geocode/reverse`.
|
||||||
|
|
||||||
|
#### `fetchCalendar(url: string, days?: number)`
|
||||||
|
```typescript
|
||||||
|
fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]>
|
||||||
|
```
|
||||||
|
Fetches and parses a remote `.ics` file via `/api/calendar`. The optional `days` parameter limits the fetch to upcoming events.
|
||||||
|
|
||||||
|
#### `parseCalendarIcs(content: string)`
|
||||||
|
```typescript
|
||||||
|
parseCalendarIcs(content: string): Promise<CalendarEvent[]>
|
||||||
|
```
|
||||||
|
Parses raw `.ics` string content via `/api/calendar/parse`.
|
||||||
|
|
||||||
|
#### `searchStation(query: string)`
|
||||||
|
```typescript
|
||||||
|
searchStation(query: string): Promise<Station[]>
|
||||||
|
```
|
||||||
|
Searches for stations by name using the HAFAS `LocMatch` method. Returns up to 5 matches with valid `extId`s.
|
||||||
|
|
||||||
|
#### `searchJourneys(fromStationExtId: string, toStationExtId: string, date: Date)`
|
||||||
|
```typescript
|
||||||
|
searchJourneys(from: string, to: string, date: Date): Promise<Journey[]>
|
||||||
|
```
|
||||||
|
Sends a HAFAS `TripSearch` request to find public transport connections between two stations. It automatically converts the `Date` to HAFAS-compatible strings.
|
||||||
|
|
||||||
|
#### `getBikeRoute(fromLat, fromLng, toLat, toLng)` & `getWalkRoute(fromLat, fromLng, toLat, toLng)`
|
||||||
|
```typescript
|
||||||
|
getBikeRoute(...): Promise<BikeRoute>
|
||||||
|
getWalkRoute(...): Promise<WalkRoute>
|
||||||
|
```
|
||||||
|
Retrieves routing data for the "first mile / last mile" segment (e.g., biking from home to the train station).
|
||||||
|
|
||||||
|
#### `findNearbyStops(lat: number, lng: number, radius?: number)`
|
||||||
|
```typescript
|
||||||
|
findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise<NearbyStop[]>
|
||||||
|
```
|
||||||
|
Finds public transport stops within a specific radius using the WienerLinien API (`/api/wienerlinien/stops`).
|
||||||
|
|
||||||
|
#### `hafasRequest<T>(body: unknown)`
|
||||||
|
```typescript
|
||||||
|
hafasRequest<T>(body: unknown): Promise<T>
|
||||||
|
```
|
||||||
|
A generic method to send arbitrary HAFAS protocol bodies to `/api/hafas`. Useful for advanced use-cases not covered by the wrapper methods.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# TimeToLeave Architecture
|
||||||
|
|
||||||
|
This document outlines the architectural decisions, directory structure, and data flow of the TimeToLeave application.
|
||||||
|
|
||||||
|
## 🏗️ Monorepo Structure
|
||||||
|
|
||||||
|
TimeToLeave uses an **npm Workspaces Monorepo** to manage its interconnected components. This allows for seamless code sharing and dependency management between the Web dashboard, the Mobile client, and the shared packages.
|
||||||
|
|
||||||
|
```text
|
||||||
|
TimeToLeave/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js 16 Web Dashboard & Backend Proxy
|
||||||
|
│ └── mobile/ # React Native 0.81 / Expo 54 Mobile Client
|
||||||
|
├── packages/
|
||||||
|
│ ├── api-client/ # Unified API Client for Backend Proxies
|
||||||
|
│ └── core/ # Shared Domain Types, Logic, and Utilities
|
||||||
|
├── docs/ # Comprehensive Documentation
|
||||||
|
└── package.json # Root Workspace Configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 Component Overview
|
||||||
|
|
||||||
|
### 1. Web Application (`apps/web`)
|
||||||
|
- **Role:** Primary dashboard for desktop users and the **Backend Proxy** for HAFAS/Geocoding APIs.
|
||||||
|
- **Framework:** Next.js 16 (App Router) with React 19.
|
||||||
|
- **Styling:** Tailwind CSS 4.
|
||||||
|
- **State Management:** React Context (`EventsProvider`, `ReminderSettingsProvider`).
|
||||||
|
- **Backend Proxy:** The Next.js API routes (`/api/*`) act as a server-side proxy. This is crucial because the HAFAS protocol and various geocoding APIs require server-side execution to protect API keys, handle CORS, and manage protocol-specific payloads.
|
||||||
|
|
||||||
|
### 2. Mobile Application (`apps/mobile`)
|
||||||
|
- **Role:** On-the-go companion app for real-time status checks and native device integration.
|
||||||
|
- **Framework:** React Native 0.81 via Expo 54.
|
||||||
|
- **Navigation:** React Navigation 7 (Native Stack Navigator).
|
||||||
|
- **Native APIs:**
|
||||||
|
- `expo-location`: Geolocation for finding nearby stations and calculating local transit (bike/walk) to the station.
|
||||||
|
- `expo-calendar`: Native integration to read local calendar events directly on the device.
|
||||||
|
- `expo-notifications`: Push notifications to alert the user when it's time to leave.
|
||||||
|
- `@react-native-async-storage/async-storage`: Persisting user settings (buffers, toggles) and cached events locally.
|
||||||
|
|
||||||
|
### 3. Core Package (`packages/core`)
|
||||||
|
- **Role:** The single source of truth for domain logic and TypeScript types across the entire monorepo.
|
||||||
|
- **Key Modules:**
|
||||||
|
- **Types:** Defines `Event`, `Journey`, `Station`, `BikeRoute`, `CountdownInfo`, and `WienerLinien` specific types.
|
||||||
|
- **HAFAS Time Parsing:** Specialized utilities to parse Vienna-centric (CET/CEST) timestamps. Handles Daylight Saving Time (DST) transitions accurately using `Intl.DateTimeFormat`.
|
||||||
|
- **Countdown & Status Logic:** Algorithms to translate raw journey data into human-readable statuses like *"Leave now"*, *"On time"*, or *"Delayed +12 min"*.
|
||||||
|
- **Formatting:** Standardized formatting for dates, times, distances, and durations.
|
||||||
|
|
||||||
|
### 4. API Client Package (`packages/api-client`)
|
||||||
|
- **Role:** A lightweight HTTP client that wraps the Web App's API routes.
|
||||||
|
- **Usage:** Used by both the Web frontend (for server/client data sync) and the Mobile client to communicate with the backend proxy.
|
||||||
|
- **Features:** Handles URL building, query parameters, and JSON serialization for HAFAS requests, calendar fetching, and routing requests.
|
||||||
|
|
||||||
|
## 🔄 Data Flow
|
||||||
|
|
||||||
|
1. **Calendar Sync:** The user provides an `.ics` URL or uploads a file. The `ApiClient` sends this to the Web App's `/api/calendar` route, which parses the events and returns standardized `CalendarEvent` objects.
|
||||||
|
2. **Station Search:** The user searches for a station. The `ApiClient` triggers a HAFAS `LocMatch` request via `/api/hafas`.
|
||||||
|
3. **Journey Calculation:** Using the station `extId` and the event time, the `ApiClient` sends a `TripSearch` request to `/api/hafas`. The backend returns real-time journey data (`Journey[]`).
|
||||||
|
4. **Local Routing:** Using `expo-location` (mobile) or browser geolocation (web), the app calculates the bike/walk route from the user's home/location to the departure station via `/api/bike-route` or `/api/walk-route`.
|
||||||
|
5. **Real-Time Status:** The `packages/core` logic continuously compares the `Journey.rD` (real departure) against the current time and local travel duration to update the Leave Status dynamically.
|
||||||
|
|
||||||
|
## ⚠️ Technical Constraints & Guidelines
|
||||||
|
|
||||||
|
- **Next.js Version:** The project uses **Next.js 16.2+**, which includes breaking changes compared to previous versions. Always refer to `node_modules/next/dist/docs/` when modifying Web routing or API conventions.
|
||||||
|
- **HAFAS Timezone:** HAFAS timestamps are strictly tied to `Europe/Vienna`. The `hafas-time.ts` module handles the bi-directional conversion between UTC `Date` objects and Vienna-local HAFAS strings. Never use standard `Date` methods for HAFAS times; always use `parseHafasTime()` and `hafasDateTime()`.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
This guide covers setting up the development environment, running the applications, and maintaining code quality.
|
||||||
|
|
||||||
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
|
- **Node.js:** Version 20.x or higher.
|
||||||
|
- **npm:** Version 9.x or higher.
|
||||||
|
|
||||||
|
## 📥 Installation
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd TimeToLeave
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
*This installs dependencies for the root workspace as well as all apps and packages.*
|
||||||
|
|
||||||
|
3. **Environment Variables:**
|
||||||
|
- Web App: Copy `apps/web/.env.example` to `apps/web/.env` and configure your HAFAS/Geocoding API keys.
|
||||||
|
- Mobile App: Copy `apps/mobile/.env.example` to `apps/mobile/.env` and set the backend API URL.
|
||||||
|
|
||||||
|
## ▶️ Running the Applications
|
||||||
|
|
||||||
|
| Script | Command | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `dev` | `npm run dev` | Starts the Next.js development server (Web) on `http://localhost:3000`. |
|
||||||
|
| `dev:mobile` | `npm run dev:mobile` | Starts the Expo development server (Mobile) and opens the Expo Go simulator. |
|
||||||
|
| `build` | `npm run build` | Builds the production bundle for the Web application. |
|
||||||
|
|
||||||
|
## 🧪 Testing & Quality Assurance
|
||||||
|
|
||||||
|
The project enforces strict typing and linting across all workspaces.
|
||||||
|
|
||||||
|
| Script | Command | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `test` | `npm run test` | Runs **Vitest** for the Web app (using jsdom & React Testing Library) and **Jest** for the Mobile app (using jest-expo). |
|
||||||
|
| `lint` | `npm run lint` | Runs **ESLint 9** across the entire monorepo. |
|
||||||
|
| `typecheck` | `npm run typecheck` | Runs **TypeScript 5** type checking across all workspaces. |
|
||||||
|
|
||||||
|
### Testing Specific Packages
|
||||||
|
If you want to run tests for a specific workspace:
|
||||||
|
```bash
|
||||||
|
cd apps/web && npm test
|
||||||
|
cd apps/mobile && npm test
|
||||||
|
cd packages/core && npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Support (Web App)
|
||||||
|
|
||||||
|
The web application includes a `Dockerfile` and `docker-compose.yml` for containerized development or deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Web App Routing (Next.js 16)
|
||||||
|
|
||||||
|
The web app uses the Next.js App Router. Key routes include:
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `/` | **Dashboard:** Lists upcoming events and their real-time leave status. |
|
||||||
|
| `/calendar` | **Calendar Sync:** Interface to import `.ics` files or paste calendar URLs. |
|
||||||
|
| `/add-event` | **Manual Entry:** Add a new event manually without a calendar source. |
|
||||||
|
| `/event/[id]` | **Event Details:** Deep dive into a specific event, showing journey options, delays, and local routing. |
|
||||||
|
|
||||||
|
### Backend API Routes
|
||||||
|
The web app acts as a proxy for external APIs. These are located in `apps/web/src/app/api/`:
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `/api/health` | `GET` | Health check for the backend proxy. |
|
||||||
|
| `/api/calendar` | `GET` | Fetch and parse remote `.ics` files. |
|
||||||
|
| `/api/calendar/parse` | `POST` | Parse raw `.ics` content. |
|
||||||
|
| `/api/hafas` | `POST` | Generic HAFAS protocol endpoint (TripSearch, LocMatch). |
|
||||||
|
| `/api/geocode` | `GET` | Forward geocoding (Nominatim). |
|
||||||
|
| `/api/geocode/reverse`| `GET` | Reverse geocoding. |
|
||||||
|
| `/api/bike-route` | `GET` | Bicycle routing between coordinates. |
|
||||||
|
| `/api/walk-route` | `GET` | Walking routing between coordinates. |
|
||||||
|
| `/api/wienerlinien/stops`| `GET` | Find nearby WienerLinien stops. |
|
||||||
|
|
||||||
|
## 📱 Mobile App Structure
|
||||||
|
|
||||||
|
The mobile app is organized into the following directories:
|
||||||
|
|
||||||
|
- `src/screens/`: UI screens (`EventListScreen`, `EventDetailScreen`, `AddEventScreen`, `CalendarImportScreen`, `SettingsScreen`).
|
||||||
|
- `src/navigation/`: React Navigation configuration (`AppNavigator.tsx`).
|
||||||
|
- `src/services/`: API integration and data fetching services.
|
||||||
|
- `src/store/`: Local state management and AsyncStorage integration.
|
||||||
|
|
||||||
|
## ⚠️ Development Notes
|
||||||
|
|
||||||
|
1. **Next.js 16 Breaking Changes:** The web app runs on Next.js 16.2+, which introduces breaking changes in routing and API conventions. Always check `node_modules/next/dist/docs/` if you encounter unexpected behavior.
|
||||||
|
2. **HAFAS Timezones:** Never parse HAFAS times using standard `Date` methods. Use `@timetoleave/core` utilities (`parseHafasTime`, `hafasDateTime`) to ensure accurate CET/CEST and DST handling.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 📚 TimeToLeave Documentation
|
||||||
|
|
||||||
|
Welcome to the official documentation for TimeToLeave, the smart departure planner that syncs with your calendar and monitors real-time public transport to tell you exactly when to leave home.
|
||||||
|
|
||||||
|
## 🗺️ Table of Contents
|
||||||
|
|
||||||
|
### 1. [Architecture](./ARCHITECTURE.md)
|
||||||
|
Understand the monorepo structure, tech stack, data flow, and component relationships between the Web Dashboard, Mobile Client, and shared packages.
|
||||||
|
|
||||||
|
### 2. [Core & API Reference](./API_REFERENCE.md)
|
||||||
|
Detailed reference for the internal packages:
|
||||||
|
- **`@timetoleave/core`**: Domain types, HAFAS time parsing, countdown/status logic, and formatting utilities.
|
||||||
|
- **`@timetoleave/api-client`**: The unified HTTP client that wraps the backend proxy routes.
|
||||||
|
|
||||||
|
### 3. [Development Guide](./DEVELOPMENT.md)
|
||||||
|
Everything a developer needs to get started:
|
||||||
|
- Prerequisites and installation instructions.
|
||||||
|
- Running the Web and Mobile apps in development mode.
|
||||||
|
- Testing (Vitest/Jest), Linting (ESLint), and Type Checking (TypeScript).
|
||||||
|
- Backend API endpoints and Next.js 16 routing conventions.
|
||||||
|
|
||||||
|
### 4. [User Guide](./USER_GUIDE.md)
|
||||||
|
A guide for end-users explaining how to sync calendars, view event details, interpret the "Leave Status" color codes, and configure mobile notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd TimeToLeave
|
||||||
|
|
||||||
|
# Install dependencies for the entire monorepo
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start the Web development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Start the Mobile development server
|
||||||
|
npm run dev:mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (Web Vitest + Mobile Jest)
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run ESLint across the monorepo
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Run TypeScript type checking
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built for developers who bike to the train and hate missing their connections.*
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# User Guide
|
||||||
|
|
||||||
|
Welcome to the TimeToLeave User Guide! This document explains how to use the TimeToLeave web dashboard and mobile application to plan your departures seamlessly.
|
||||||
|
|
||||||
|
## 🚀 How TimeToLeave Works
|
||||||
|
|
||||||
|
TimeToLeave acts as your personal departure planner. Instead of manually checking train schedules, you simply sync your calendar. The app calculates the best public transport connections to your upcoming appointments, monitors real-time delays, and tells you exactly when to leave home.
|
||||||
|
|
||||||
|
### Core Workflow
|
||||||
|
1. **Sync Your Calendar:** Import your `.ics` file or provide a calendar URL.
|
||||||
|
2. **Set Your Origin:** Define your home station or let the app use your current location.
|
||||||
|
3. **Automatic Planning:** The app queries real-time public transport data (via HAFAS and WienerLinien) to find the best connections.
|
||||||
|
4. **Leave Status:** The dashboard displays a clear status: `Leave now`, `On time`, `Delayed +X min`, or `Departure missed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Web Dashboard
|
||||||
|
|
||||||
|
The web dashboard is designed for planning and monitoring your upcoming events from a desktop or laptop.
|
||||||
|
|
||||||
|
### Main Dashboard (`/`)
|
||||||
|
This is the default view when you open the application. It displays a list of your upcoming events sorted by date. Each event card shows:
|
||||||
|
- **Event Title & Time:** The name of the appointment and when it starts.
|
||||||
|
- **Destination Station:** The nearest station to your event.
|
||||||
|
- **Leave Status:** A color-coded indicator of your current standing:
|
||||||
|
- 🔴 **Red:** Time is up ("Now") or you're extremely close to departure.
|
||||||
|
- 🟠 **Orange:** Urgent! You need to leave within 10 minutes.
|
||||||
|
- 🟡 **Yellow:** Moderate urgency (within 30 minutes).
|
||||||
|
- 🟢 **Green:** Relaxed (within 60 minutes).
|
||||||
|
- 🔵 **Blue:** Plenty of time remaining (over 1 hour).
|
||||||
|
|
||||||
|
### Calendar Sync (`/calendar`)
|
||||||
|
Use this page to connect your personal calendar.
|
||||||
|
- **URL Import:** Paste a public `.ics` calendar URL. The app will fetch upcoming events automatically.
|
||||||
|
- **File Import:** Upload a `.ics` file directly from your computer.
|
||||||
|
|
||||||
|
### Event Details (`/event/[id]`)
|
||||||
|
Click on any event from the dashboard to view detailed planning information:
|
||||||
|
- **Journey Options:** A list of available trains/buses with scheduled vs. real departure times.
|
||||||
|
- **Delay Information:** Real-time delays are highlighted. Cancelled journeys are clearly marked.
|
||||||
|
- **Local Routing:** See how long it takes to bike or walk from your home to the departure station.
|
||||||
|
|
||||||
|
### Manual Event Entry (`/add-event`)
|
||||||
|
If you don't have a calendar synced, or you have a one-off appointment, you can manually add an event by specifying the title, destination, and time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Mobile Application
|
||||||
|
|
||||||
|
The mobile app is perfect for on-the-go checks, leveraging your phone's native capabilities.
|
||||||
|
|
||||||
|
### Event List Screen
|
||||||
|
The home screen mirrors the web dashboard, showing your upcoming events and their real-time status. You can pull-to-refresh to get the latest transit data.
|
||||||
|
|
||||||
|
### Event Detail Screen
|
||||||
|
Tap on an event to see:
|
||||||
|
- The best journey options and real-time platform information.
|
||||||
|
- A countdown timer to your departure.
|
||||||
|
- Step-by-step bike/walk directions to the station.
|
||||||
|
|
||||||
|
### Calendar Import Screen
|
||||||
|
Import your calendar directly on the device. The mobile app can read your device's native calendar apps (via `expo-calendar`) if you prefer not to use a remote `.ics` URL.
|
||||||
|
|
||||||
|
### Settings Screen
|
||||||
|
Customize your experience:
|
||||||
|
- **Buffer Time:** Set a default buffer (e.g., arrive 5 minutes early).
|
||||||
|
- **Departure Buffer:** Add extra time for the actual transit journey.
|
||||||
|
- **Toggle Options:** Enable/disable the walking or biking route suggestions based on your preference.
|
||||||
|
- **Notifications:** Configure push notifications so you get an alert exactly when it's time to leave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ Understanding "Leave Status"
|
||||||
|
|
||||||
|
The "Leave Status" is the heart of TimeToLeave. It is calculated dynamically by comparing the **real departure time** of your best non-cancelled journey against the **current time**.
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Leave now** | Your train departs within 15 minutes. Head out! |
|
||||||
|
| **On time** | Everything is running smoothly, and you have a comfortable window. |
|
||||||
|
| **Delayed +X min** | Your train is delayed. You can stay home a bit longer! |
|
||||||
|
| **Departure missed** | The best available journey has already departed. A new search may be required. |
|
||||||
|
| **All journeys cancelled** | Unfortunately, all connections for this time slot are cancelled. |
|
||||||
|
|
||||||
|
## 🔒 Privacy & Data
|
||||||
|
|
||||||
|
TimeToLeave is designed with privacy in mind:
|
||||||
|
- Calendar data is processed server-side solely for the purpose of event extraction and is not permanently stored beyond the active session.
|
||||||
|
- Geolocation data is used exclusively for calculating routes and finding nearby stations. It is never shared with third parties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Happy traveling! Built for developers who bike to the train and hate missing their connections.*
|
||||||
+6
-6
@@ -1,5 +1,9 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import ts from "typescript-eslint";
|
import ts from "typescript-eslint";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const runtimeGlobals = {
|
const runtimeGlobals = {
|
||||||
AbortSignal: "readonly",
|
AbortSignal: "readonly",
|
||||||
@@ -17,12 +21,7 @@ const runtimeGlobals = {
|
|||||||
|
|
||||||
export default ts.config(
|
export default ts.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ["**/dist/**", "**/.next/**", "**/node_modules/**", "**/coverage/**"],
|
||||||
"**/dist/**",
|
|
||||||
"**/.next/**",
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/coverage/**",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...ts.configs.recommended],
|
extends: [js.configs.recommended, ...ts.configs.recommended],
|
||||||
@@ -32,6 +31,7 @@ export default ts.config(
|
|||||||
globals: runtimeGlobals,
|
globals: runtimeGlobals,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
|
tsconfigRootDir: path.resolve(__dirname),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+22
-1
@@ -1 +1,22 @@
|
|||||||
apps/web/src/*
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [],
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "apps/web" },
|
||||||
|
{ "path": "apps/mobile" },
|
||||||
|
{ "path": "packages/core" },
|
||||||
|
{ "path": "packages/api-client" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user