mobile app phase 1
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@timetoleave/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Countdown utilities for TimeToLeave
|
||||
import { CountdownInfo } from "./types";
|
||||
|
||||
export function calculateCountdown(targetDate: Date): CountdownInfo {
|
||||
const now = new Date();
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin <= 0) {
|
||||
return {
|
||||
label: "Now",
|
||||
color: "red",
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 10) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "orange",
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 30) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "yellow",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 60) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "green",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hours = Math.floor(diffMin / 60);
|
||||
const remainingMin = diffMin % 60;
|
||||
return {
|
||||
label: `${hours}h ${remainingMin}min`,
|
||||
color: "blue",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Formatting utilities for TimeToLeave
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString("de-AT", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date): string {
|
||||
return date.toLocaleString("de-AT", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}min`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${meters}m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)}km`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Timezone-aware parsing for HAFAS times.
|
||||
// HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing
|
||||
// regardless of the server or browser timezone.
|
||||
|
||||
// Helper: get the offset (in minutes) from UTC for a given instant in a specific
|
||||
// IANA timezone.
|
||||
//
|
||||
// CAVEAT — Hour-level precision only. This helper reads the hour via
|
||||
// Intl.DateTimeFormat (hour part only), so it returns incorrect results for
|
||||
// timezones with fractional-hour offsets (e.g. India +05:30, Nepal +05:45).
|
||||
// It is designed exclusively for "Europe/Vienna" (CET/CEST), which only uses
|
||||
// full-hour offsets (+01:00 / +02:00). Do not reuse for other timezones.
|
||||
function getTimezoneOffsetMinutes(instant: Date, tz: string): number {
|
||||
const getHour = (timeZone: string) => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(instant);
|
||||
return parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
||||
};
|
||||
|
||||
const utcH = getHour("UTC");
|
||||
const tzH = getHour(tz);
|
||||
|
||||
let diff = tzH - utcH;
|
||||
// Handle midnight wraparound:
|
||||
// e.g. UTC 23 → Vienna 01: diff = -22, but actual offset is +2.
|
||||
// Vienna is always between +1 and +2, so |diff| > 12 means we crossed midnight.
|
||||
if (diff < -12) diff += 24;
|
||||
if (diff > 12) diff -= 24;
|
||||
|
||||
return diff * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a HAFAS date+time string into a proper JavaScript Date.
|
||||
* HAFAS dates are "YYYYMMDD" and times are "HHMMSS".
|
||||
* The resulting Date represents the correct UTC instant for the Vienna local time.
|
||||
*
|
||||
* We try each plausible Vienna offset (CET +60 or CEST +120), compute the
|
||||
* candidate UTC timestamp via Date.UTC, and verify by checking that Vienna's
|
||||
* offset at that candidate instant matches the assumed offset. The first match
|
||||
* wins. This correctly handles DST transitions.
|
||||
*
|
||||
* Ambiguous times during the fall-back DST transition (02:xx on Oct 27, 2024):
|
||||
* HAFAS resolves ambiguous local times by using the post-transition (standard)
|
||||
* interpretation. Our loop tries CET (+60) before CEST (+120), so ambiguous
|
||||
* hours naturally resolve to CET — matching HAFAS convention. For example
|
||||
* "023000" on "20241027" → Oct 27 01:30:00 UTC (the second 02:30, not the
|
||||
* first).
|
||||
*/
|
||||
export function parseHafasTime(dateStr: string, timeStr: string): Date {
|
||||
const y = parseInt(dateStr.slice(0, 4), 10);
|
||||
const mo = parseInt(dateStr.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(dateStr.slice(6, 8), 10);
|
||||
const h = parseInt(timeStr.slice(0, 2), 10);
|
||||
const m = parseInt(timeStr.slice(2, 4), 10);
|
||||
const s = parseInt(timeStr.slice(4, 6), 10);
|
||||
|
||||
for (const offsetMin of [60, 120]) {
|
||||
const candidateTs = Date.UTC(y, mo, d, h, m, s, 0) - offsetMin * 60_000;
|
||||
const actualOffset = getTimezoneOffsetMinutes(new Date(candidateTs), "Europe/Vienna");
|
||||
if (actualOffset === offsetMin) {
|
||||
return new Date(candidateTs);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — should not happen for valid Vienna times.
|
||||
// Treat as CET (+1).
|
||||
return new Date(Date.UTC(y, mo, d, h, m, s, 0) - 60 * 60_000);
|
||||
}
|
||||
|
||||
// Helper: extract date/time components of an instant in a given IANA timezone
|
||||
// via Intl.DateTimeFormat. Returns { year, month (1-based), day, hour, minute, second }.
|
||||
function getDateTimeParts(instant: Date, tz: string) {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = fmt.formatToParts(instant);
|
||||
const get = (type: string) => parseInt(parts.find((p) => p.type === type)!.value, 10);
|
||||
|
||||
return {
|
||||
year: get("year"),
|
||||
month: get("month"), // 1-based
|
||||
day: get("day"),
|
||||
hour: get("hour"),
|
||||
minute: get("minute"),
|
||||
second: get("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HAFAS date and time strings from a JavaScript Date.
|
||||
*
|
||||
* Components are extracted in Europe/Vienna so the output is always a valid
|
||||
* Vienna-local HAFAS timestamp, regardless of the server's timezone.
|
||||
* This is the inverse of parseHafasTime: hafasDateTime(parseHafasTime(d, t))
|
||||
* will return { date: d, time: t } for every valid Vienna date/time pair.
|
||||
*/
|
||||
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`;
|
||||
return { date: d, time: t };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Re-export everything for clean imports
|
||||
export * from './types';
|
||||
export * from './countdown-utils';
|
||||
export * from './formatting';
|
||||
export * from './status-utils';
|
||||
export * from './hafas-time';
|
||||
@@ -0,0 +1,16 @@
|
||||
// Status utilities for TimeToLeave
|
||||
import type { ServerStatus } from "./types";
|
||||
|
||||
export class StatusUtils {
|
||||
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Core domain types
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: string;
|
||||
eventTime: Date;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: string;
|
||||
eventTime: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
name: string;
|
||||
extId: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface Journey {
|
||||
id: string;
|
||||
sD: Date; // scheduled departure
|
||||
rD: Date; // real departure
|
||||
sA: Date; // scheduled arrival
|
||||
rA: Date; // real arrival
|
||||
delay: number; // delay in minutes
|
||||
platform: string;
|
||||
changes: number;
|
||||
trains: string[];
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface BikeStep {
|
||||
name: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface BikeRoute {
|
||||
distance: number;
|
||||
duration: number;
|
||||
steps: BikeStep[];
|
||||
}
|
||||
|
||||
export interface CountdownInfo {
|
||||
label: string;
|
||||
color: string;
|
||||
urgent: boolean;
|
||||
}
|
||||
|
||||
export type LocState = "pending" | "granted" | "denied";
|
||||
export type CalStatus = null | "loading" | "ok" | "error";
|
||||
|
||||
// ── Reminder Settings ──────────────────────────────────
|
||||
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ServerStatus = boolean | null;
|
||||
|
||||
export interface GeocodeResult {
|
||||
lat: number;
|
||||
lng: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// WienerLinien types
|
||||
|
||||
export interface WienerLinienLine {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface WienerLinienStop {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface WienerLinienDeparture {
|
||||
stopId: string;
|
||||
line: WienerLinienLine;
|
||||
direction: string;
|
||||
departureTime: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface WienerLinienMonitorResponse {
|
||||
stops: {
|
||||
stopId: string;
|
||||
departures: WienerLinienDeparture[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface NearbyStop {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user