Add CI pipeline, pre-commit hooks, and offline caching
CI / lint-typecheck-test (push) Has been cancelled

Configure GitHub Actions for linting, typechecking, and testing.
Add Husky and lint-staged for pre-commit checks. Implement API
response caching in AsyncStorage for offline support. Move core
tests to packages/core and add vitest configuration.
This commit is contained in:
2026-05-19 14:00:38 +02:00
parent 0493fcc939
commit 72f9400756
18 changed files with 809 additions and 2552 deletions
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
root: true,
extends: ['next/core-web-vitals'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
ignorePatterns: ['node_modules/', '.next/', 'out/', 'dist/'],
};
+8 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import EventCard from "@/app/event/EventCard";
@@ -15,9 +16,13 @@ export default function Home() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const upcoming = events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
const upcoming = useMemo(
() =>
events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()),
[events]
);
const nextEvent = upcoming[0] ?? null;
return (
@@ -1,63 +0,0 @@
import { describe, it, expect } from "vitest";
import { calculateCountdown } from "@timetoleave/core";
describe("countdown-utils", () => {
describe("calculateCountdown", () => {
it("should return 'Now' for events that have already passed", () => {
const pastDate = new Date(Date.now() - 1000);
const result = calculateCountdown(pastDate);
expect(result.label).toBe("Now");
expect(result.color).toBe("red");
expect(result.urgent).toBe(true);
});
it("should return 'Xmin' for events within 10 minutes", () => {
const soonDate = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now
const result = calculateCountdown(soonDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("orange");
expect(result.urgent).toBe(true);
});
it("should return 'Xmin' for events within 30 minutes", () => {
const mediumDate = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
const result = calculateCountdown(mediumDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("yellow");
expect(result.urgent).toBe(false);
});
it("should return 'Xmin' for events within 60 minutes", () => {
const mediumDate = new Date(Date.now() + 45 * 60 * 1000); // 45 minutes from now
const result = calculateCountdown(mediumDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("green");
expect(result.urgent).toBe(false);
});
it("should return 'Xh Ymin' for events beyond 60 minutes", () => {
const farDate = new Date(Date.now() + 90 * 60 * 1000); // 90 minutes from now
const result = calculateCountdown(farDate);
expect(result.label).toMatch(/\d+h \d+min/);
expect(result.color).toBe("blue");
expect(result.urgent).toBe(false);
});
it("should handle exact time boundaries correctly", () => {
// Exactly 10 minutes
const tenMinDate = new Date(Date.now() + 10 * 60 * 1000);
const result = calculateCountdown(tenMinDate);
expect(result.color).toBe("orange"); // Should be orange for 10 minutes or less
// Exactly 30 minutes
const thirtyMinDate = new Date(Date.now() + 30 * 60 * 1000);
const result2 = calculateCountdown(thirtyMinDate);
expect(result2.color).toBe("yellow"); // Should be yellow for 30 minutes or less
// Exactly 60 minutes
const sixtyMinDate = new Date(Date.now() + 60 * 60 * 1000);
const result3 = calculateCountdown(sixtyMinDate);
expect(result3.color).toBe("green"); // Should be green for 60 minutes or less
});
});
});
@@ -1,313 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseHafasTime, hafasDateTime } from "@timetoleave/core";
describe("getTimezoneOffsetMinutes (via parseHafasTime)", () => {
/*
* We can't test getTimezoneOffsetMinutes directly (not exported), but we can
* validate its correctness by checking that parseHafasTime produces the
* expected UTC timestamps for known Vienna local times.
*
* CET = UTC+1 (offset 60 min) — winter
* CEST = UTC+2 (offset 120 min) — summer
*
* To verify independence from server timezone, we compare the resulting
* Date's UTC components (getUTCHours, getUTCMinutes, etc.).
*/
describe("Standard Winter (CET, UTC+1)", () => {
it("parses 08:00 Vienna (Jan) as 07:00 UTC", () => {
const d = parseHafasTime("20240115", "080000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(0); // January
expect(d.getUTCDate()).toBe(15);
expect(d.getUTCHours()).toBe(7);
expect(d.getUTCMinutes()).toBe(0);
expect(d.getUTCSeconds()).toBe(0);
});
it("parses midnight Vienna as 23:00 UTC previous day", () => {
const d = parseHafasTime("20240201", "000000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(0); // January
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(23);
expect(d.getUTCMinutes()).toBe(0);
});
it("parses 23:59 Vienna as 22:59 UTC same day", () => {
const d = parseHafasTime("20241231", "235900");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(22);
expect(d.getUTCMinutes()).toBe(59);
});
});
describe("Standard Summer (CEST, UTC+2)", () => {
it("parses 08:00 Vienna (July) as 06:00 UTC", () => {
const d = parseHafasTime("20240715", "080000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(6); // July
expect(d.getUTCDate()).toBe(15);
expect(d.getUTCHours()).toBe(6);
expect(d.getUTCMinutes()).toBe(0);
});
it("parses midnight Vienna as 22:00 UTC previous day", () => {
const d = parseHafasTime("20240801", "000000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCMonth()).toBe(6); // July
expect(d.getUTCHours()).toBe(22);
});
});
describe("Spring DST Transition (March 31, 2024)", () => {
/*
* In Austria 2024:
* - At 02:00 CET clocks jump to 03:00 CEST
* - 02:00-02:59 does NOT exist
* - 01:59 CET → 03:00 CEST
*/
it("treats 01:00 on Mar 31 as CET (UTC+1) → 00:00 UTC", () => {
const d = parseHafasTime("20240331", "010000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(0);
expect(d.getUTCMinutes()).toBe(0);
});
it("treats 03:00 on Mar 31 as CEST (UTC+2) → 01:00 UTC", () => {
const d = parseHafasTime("20240331", "030000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(0);
});
it("treats 12:00 on Mar 31 as CEST (UTC+2) → 10:00 UTC", () => {
const d = parseHafasTime("20240331", "120000");
expect(d.getUTCHours()).toBe(10);
});
it("treats 00:00 on Mar 31 as CET (still before transition) → 23:00 UTC Mar 30", () => {
const d = parseHafasTime("20240331", "000000");
expect(d.getUTCDate()).toBe(30);
expect(d.getUTCHours()).toBe(23);
});
});
describe("Fall DST Transition (October 27, 2024)", () => {
/*
* In Austria 2024:
* - At 03:00 CEST clocks go back to 02:00 CET
* - 02:00-02:59 occurs twice (ambiguous)
* - Before 03:00 CEST is UTC+2, after is UTC+1
*/
it("treats 00:00 on Oct 27 as CEST (before transition) → 22:00 UTC Oct 26", () => {
const d = parseHafasTime("20241027", "000000");
expect(d.getUTCDate()).toBe(26);
expect(d.getUTCHours()).toBe(22);
});
it("treats 01:00 on Oct 27 as CEST (before transition) → 23:00 UTC Oct 26", () => {
const d = parseHafasTime("20241027", "010000");
expect(d.getUTCDate()).toBe(26);
expect(d.getUTCHours()).toBe(23);
});
it("treats 03:00 on Oct 27 as CET (after transition) → 02:00 UTC", () => {
// 03:00 CEST never fires — at 03:00 the clock becomes 02:00 CET.
// The iterative verification picks whichever offset the Intl API
// reports at the resulting candidate. For 03:00 on this day the
// offset will be +1 (CET), so 03:00 → 02:00 UTC.
const d = parseHafasTime("20241027", "030000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(2);
});
it("resolves ambiguous 02:30 to post-transition CET → 01:30 UTC", () => {
// During fall-back, 02:30 occurs twice:
// first 02:30 CEST = Oct 26 00:30 UTC
// second 02:30 CET = Oct 27 01:30 UTC
// HAFAS uses the post-transition (standard-time) interpretation.
// Our loop tries CET (+60) before CEST (+120), so this is the natural
// resolution — matching HAFAS convention.
const d = parseHafasTime("20241027", "023000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(30);
});
it("resolves ambiguous 02:00 to post-transition CET → 01:00 UTC", () => {
const d = parseHafasTime("20241027", "020000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(0);
});
it("resolves ambiguous 02:59 to post-transition CET → 01:59 UTC", () => {
const d = parseHafasTime("20241027", "025959");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(59);
expect(d.getUTCSeconds()).toBe(59);
});
it("treats 12:00 on Oct 27 as CET (after transition) → 11:00 UTC", () => {
const d = parseHafasTime("20241027", "120000");
expect(d.getUTCHours()).toBe(11);
});
});
describe("Day after transition", () => {
it("Apr 1 (day after spring) is solidly CEST → 08:00 = 06:00 UTC", () => {
const d = parseHafasTime("20240401", "080000");
expect(d.getUTCHours()).toBe(6);
});
it("Oct 28 (day after fall) is solidly CET → 08:00 = 07:00 UTC", () => {
const d = parseHafasTime("20241028", "080000");
expect(d.getUTCHours()).toBe(7);
});
});
describe("Leap year", () => {
it("parses Feb 29 correctly", () => {
const d = parseHafasTime("20240229", "120000");
expect(d.getUTCMonth()).toBe(1); // February
// CET in Feb, so 12:00 Vienna = 11:00 UTC
expect(d.getUTCDate()).toBe(29);
expect(d.getUTCHours()).toBe(11);
});
});
});
describe("hafasDateTime", () => {
/*
* hafasDateTime is the inverse of parseHafasTime: it takes a JavaScript
* Date (UTC instant) and returns the Vienna-local HAFAS date/time strings.
* The roundtrip hafasDateTime(parseHafasTime(d, t)) === {date: d, time: t}
* must hold for every valid Vienna local time.
*/
describe("roundtrip through parseHafasTime", () => {
function roundtrip(dateStr: string, timeStr: string) {
const parsed = parseHafasTime(dateStr, timeStr);
const { date, time } = hafasDateTime(parsed);
return { date, time };
}
it("roundtrips standard CET times", () => {
expect(roundtrip("20240115", "080000")).toEqual({
date: "20240115",
time: "080000",
});
expect(roundtrip("20240201", "000000")).toEqual({
date: "20240201",
time: "000000",
});
expect(roundtrip("20241231", "235900")).toEqual({
date: "20241231",
time: "235900",
});
});
it("roundtrips standard CEST times", () => {
expect(roundtrip("20240715", "080000")).toEqual({
date: "20240715",
time: "080000",
});
expect(roundtrip("20240801", "000000")).toEqual({
date: "20240801",
time: "000000",
});
});
it("roundtrips spring DST transition (Mar 31, 2024)", () => {
expect(roundtrip("20240331", "010000")).toEqual({
date: "20240331",
time: "010000",
});
expect(roundtrip("20240331", "030000")).toEqual({
date: "20240331",
time: "030000",
});
expect(roundtrip("20240331", "120000")).toEqual({
date: "20240331",
time: "120000",
});
});
it("roundtrips fall DST transition (Oct 27, 2024)", () => {
expect(roundtrip("20241027", "000000")).toEqual({
date: "20241027",
time: "000000",
});
expect(roundtrip("20241027", "023000")).toEqual({
date: "20241027",
time: "023000",
});
expect(roundtrip("20241027", "120000")).toEqual({
date: "20241027",
time: "120000",
});
});
it("roundtrips leap year", () => {
expect(roundtrip("20240229", "120000")).toEqual({
date: "20240229",
time: "120000",
});
});
});
describe("UTC-based correctness", () => {
it("converts a UTC instant to correct Vienna HAFAS strings (CET)", () => {
// Jan 15 07:00 UTC = Jan 15 08:00 Vienna (CET)
const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("080000");
});
it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => {
// Jul 15 06:00 UTC = Jul 15 08:00 Vienna (CEST)
const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240715");
expect(time).toBe("080000");
});
it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => {
// Jan 14 23:00 UTC = Jan 15 00:00 Vienna (CET)
const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("000000");
});
it('produces "00" for midnight, not "24" (k-clock safety)', () => {
// Older ICU / Node can render midnight as "24" with hour: "numeric" + hour12: false.
// We use hour: "2-digit" which guarantees "00" through "23".
// 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("000000");
});
it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => {
// Vienna Jan 1 00:00 CET = Dec 31 23:00 UTC
const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240101");
expect(time).toBe("000000");
});
it("pads single-digit components correctly", () => {
// Jan 2 08:03:07 Vienna (CET) = Jan 2 07:03:07 UTC
const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240102");
expect(time).toBe("080307");
});
});
});