Add CI pipeline, pre-commit hooks, and offline caching
CI / lint-typecheck-test (push) Has been cancelled
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:
@@ -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/'],
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user