From 3bb97cf895c32ef4abafe30ec07d472568c62cd8 Mon Sep 17 00:00:00 2001 From: lanttu1243 <45914586+lanttu1243@users.noreply.github.com> Date: Sun, 19 Jan 2025 22:42:32 +0200 Subject: [PATCH] Infoscreen for guild room (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: info screen * Add files to branch after big fuckup * add fetcher * debug * Hello * hups * Add debug message * fix process.env.* functioning * Fix issue where events next year show as the first events on the list :D * something * Change PUBLIC_**_URL to NEXT_PUBLIC... * More infonäyttö things - Fixed scrolling between pages - Store state at parent component - Misc things * fix: Formatted infonäyttö files * fix: Changed rest of PUBLIC_ env variable references to NEXT_PUBLIC_ * Change PUBLIC_**_URL to NEXT_PUBLIC_ * fix: Align stop names on HSL schedule with grid * Refactor folder structure for infoscreen components * remove hsl page * format * add kanttiinit functionality * Add kanttiinit stuff * feat: Update Kanttiinit implementation - Now it works :D - Created separate API endpoints for accessing Kanttiinit API - Changed types for Kanttiinit - Updated almost all files that touch Kanttiinit on infoscreen * feat: Added basic styling to Kanttiinit on infoscreen * Something * Make infoscreen fullscreen capable * fix some typecheck issues * format and lint * Fix Raide-Jokeri writing form * fix: Fixed typecheck errors in Infoscreen - Full screen button now only works on new browsers (Safari 2023+, most others 2020+) - Removed unused Event component - Better error response for Kanttiinit api handler * Removed Enter Fullscreen button from InfoScreen * fix: Fixed formatting * Revert global env variable changes * Remove non-relevant files, fixed some issues * Removed commented code, console.logs etc * Fixed Header logo on Infoscreen, 2025 payload update to make tests pass, removed unused json-stable-stringify * Fixed formatting and some warnings * Moved dependency to correct package.json * Added one newline to fix formatting :) * Fix several warnings * Move types, small refactoring, etc * Revert infoscreen layout/page structure change - It seems that it is mandatory to have both layout and page files * Refactored infonaytto contents file * Convert HSL & Kanttiinit to Server Components on infoscreen - Event page is currently not working * chore: cleanup for hsl stuff mostly * chore: Add caching for Kanttiinit API, fix arrivalTimeUnix parameter name * feat: Initial event display support for infoscreen * feat: Refactor Infoscreen events page to use global event components - Added new EventCardCompact component * feat: Add localization to infoscreen, minor layout fixes * feat: Fetch localized Kantiinit menus, change infoscreen path - Infoscreen is now /infoscreen instead of /infonaytto/naytto - Kanttiinit menus are now fetched in Finnish and English depending on locale * refactor: Update infoscreen component structure - Renamed `kanttiinit-combined` to just `kanttiinit` - Combined kanttiinit helper functions to `kanttiinit/fetcher.ts` - Moved locale handling to kanttiinit React component - Combined `hsl-schedules-combined` and `hsl-schedule` to `hsl-schedules` - Update event grouping week logic on infoscreen - Revert the change to NEXT_PUBLIC_ILMOMASIINA_URL - Revert moving of `globals.css` * Fix: uncomment HSL and Kanttiinit screens in InfoScreenContents --------- Co-authored-by: puhakkn2 Co-authored-by: IiroP Co-authored-by: Kalle Ahlström Co-authored-by: Kalle Ahlström <71292737+kahlstrm@users.noreply.github.com> --- .env.example | 4 +- apps/web/package.json | 1 + .../(infoscreen)/infoscreen/layout.tsx | 36 +++ .../[locale]/(infoscreen)/infoscreen/page.tsx | 16 ++ .../[locale]/{ => (main)}/[...path]/page.tsx | 22 +- .../src/app/[locale]/{ => (main)}/error.tsx | 2 +- .../events/[...ilmomasiinaPath]/page.tsx | 0 .../{ => (main)}/events/[slug]/page.tsx | 16 +- .../events/[slug]/signup-button.tsx | 2 +- .../[locale]/{ => (main)}/global-error.tsx | 2 +- .../src/app/[locale]/{ => (main)}/layout.tsx | 16 +- .../app/[locale]/{ => (main)}/not-found.tsx | 4 +- .../src/app/[locale]/{ => (main)}/page.tsx | 14 +- .../[signupId]/[signupEditToken]/page.tsx | 13 +- .../[signupEditToken]/signup-form.tsx | 8 +- .../{ => (main)}/signups/not-found.tsx | 4 +- .../tapahtumat/[...ilmomasiinaPath]/page.tsx | 0 .../{ => (main)}/tapahtumat/[slug]/page.tsx | 0 .../{ => (main)}/tapahtumat/not-found.tsx | 4 +- .../viikkotiedotteet/[slug]/page.tsx | 0 .../weekly-newsletters/[slug]/page.tsx | 2 +- .../src/app/next_api/fetch-hsl-stops/route.ts | 229 ------------------ apps/web/src/components/event-card/index.tsx | 113 +++++++++ .../infoscreen/events-list/index.tsx | 63 +++++ .../infoscreen/hsl-schedules/fetcher.ts | 168 +++++++++++++ .../infoscreen/hsl-schedules/index.tsx | 85 +++++++ .../infoscreen/infoscreen-header/index.tsx | 44 ++++ .../infoscreen/infoscreen-switcher/index.tsx | 38 +++ .../infoscreen/kanttiinit/fetcher.ts | 141 +++++++++++ .../infoscreen/kanttiinit/index.tsx | 44 ++++ .../infoscreen/types/hsl-helper-types.ts | 32 +++ .../infoscreen/types/kanttiinit-types.ts | 36 +++ apps/web/src/lib/types/hsl-helper-types.ts | 53 ---- apps/web/src/locales/en.ts | 6 + apps/web/src/locales/fi.ts | 6 + pnpm-lock.yaml | 69 +++--- 36 files changed, 920 insertions(+), 373 deletions(-) create mode 100644 apps/web/src/app/[locale]/(infoscreen)/infoscreen/layout.tsx create mode 100644 apps/web/src/app/[locale]/(infoscreen)/infoscreen/page.tsx rename apps/web/src/app/[locale]/{ => (main)}/[...path]/page.tsx (85%) rename apps/web/src/app/[locale]/{ => (main)}/error.tsx (97%) rename apps/web/src/app/[locale]/{ => (main)}/events/[...ilmomasiinaPath]/page.tsx (100%) rename apps/web/src/app/[locale]/{ => (main)}/events/[slug]/page.tsx (96%) rename apps/web/src/app/[locale]/{ => (main)}/events/[slug]/signup-button.tsx (90%) rename apps/web/src/app/[locale]/{ => (main)}/global-error.tsx (98%) rename apps/web/src/app/[locale]/{ => (main)}/layout.tsx (84%) rename apps/web/src/app/[locale]/{ => (main)}/not-found.tsx (92%) rename apps/web/src/app/[locale]/{ => (main)}/page.tsx (86%) rename apps/web/src/app/[locale]/{ => (main)}/signups/[signupId]/[signupEditToken]/page.tsx (88%) rename apps/web/src/app/[locale]/{ => (main)}/signups/[signupId]/[signupEditToken]/signup-form.tsx (98%) rename apps/web/src/app/[locale]/{ => (main)}/signups/not-found.tsx (92%) rename apps/web/src/app/[locale]/{ => (main)}/tapahtumat/[...ilmomasiinaPath]/page.tsx (100%) rename apps/web/src/app/[locale]/{ => (main)}/tapahtumat/[slug]/page.tsx (100%) rename apps/web/src/app/[locale]/{ => (main)}/tapahtumat/not-found.tsx (92%) rename apps/web/src/app/[locale]/{ => (main)}/viikkotiedotteet/[slug]/page.tsx (100%) rename apps/web/src/app/[locale]/{ => (main)}/weekly-newsletters/[slug]/page.tsx (73%) delete mode 100644 apps/web/src/app/next_api/fetch-hsl-stops/route.ts create mode 100644 apps/web/src/components/infoscreen/events-list/index.tsx create mode 100644 apps/web/src/components/infoscreen/hsl-schedules/fetcher.ts create mode 100644 apps/web/src/components/infoscreen/hsl-schedules/index.tsx create mode 100644 apps/web/src/components/infoscreen/infoscreen-header/index.tsx create mode 100644 apps/web/src/components/infoscreen/infoscreen-switcher/index.tsx create mode 100644 apps/web/src/components/infoscreen/kanttiinit/fetcher.ts create mode 100644 apps/web/src/components/infoscreen/kanttiinit/index.tsx create mode 100644 apps/web/src/components/infoscreen/types/hsl-helper-types.ts create mode 100644 apps/web/src/components/infoscreen/types/kanttiinit-types.ts delete mode 100644 apps/web/src/lib/types/hsl-helper-types.ts diff --git a/.env.example b/.env.example index ee20ae18..2f466dd8 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ PAYLOAD_PUBLIC_LOCAL_DEVELOPMENT=true NEXT_REVALIDATION_KEY="veryprivatekey" -# Digitransit API key for HSL traffic data for info screen www.digitransit.fi/en/developers/api-registration/ -DIGITRANSIT_SUBSCRIPTION_KEY="very secret stuff" +# Digitransit API key for hsl traffic data for info screen www.digitransit.fi/en/developers/api-registration/ +DIGITRANSIT_SUBSCRIPTION_KEY="Replace me" PUBLIC_FRONTEND_URL="http://localhost:3000" PUBLIC_SERVER_URL="http://localhost:3001" diff --git a/apps/web/package.json b/apps/web/package.json index 7f581054..4f125e76 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "react": "catalog:react19", "react-big-calendar": "^1.17.0", "react-dom": "catalog:react19", + "react-live-clock": "^6.1.22", "react-markdown": "^9.0.1", "remark": "^15.0.1", "remark-gfm": "^4.0.0", diff --git a/apps/web/src/app/[locale]/(infoscreen)/infoscreen/layout.tsx b/apps/web/src/app/[locale]/(infoscreen)/infoscreen/layout.tsx new file mode 100644 index 00000000..2e4f1e79 --- /dev/null +++ b/apps/web/src/app/[locale]/(infoscreen)/infoscreen/layout.tsx @@ -0,0 +1,36 @@ +import React from "react"; +// eslint-disable-next-line camelcase -- Roboto_Mono name is set by next/font +import { Inter, Roboto_Mono } from "next/font/google"; +import { cn } from "../../../../lib/utils.ts"; +import "../../globals.css"; +import { InfoScreenHeader } from "../../../../components/infoscreen/infoscreen-header/index.tsx"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); +const robotoMono = Roboto_Mono({ + subsets: ["latin"], + variable: "--font-roboto-mono", +}); + +export default function ScreenLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
{children}
+ + + ); +} diff --git a/apps/web/src/app/[locale]/(infoscreen)/infoscreen/page.tsx b/apps/web/src/app/[locale]/(infoscreen)/infoscreen/page.tsx new file mode 100644 index 00000000..e4930e8a --- /dev/null +++ b/apps/web/src/app/[locale]/(infoscreen)/infoscreen/page.tsx @@ -0,0 +1,16 @@ +import { HSLcombinedSchedule } from "../../../../components/infoscreen/hsl-schedules"; +import { KanttiinitCombined } from "../../../../components/infoscreen/kanttiinit"; +import InfoScreenSwitcher from "../../../../components/infoscreen/infoscreen-switcher/index"; +import EventListInfoscreen from "../../../../components/infoscreen/events-list"; + +export const dynamic = "force-dynamic"; + +export default function InfoScreenContents() { + return ( + + + + + + ); +} diff --git a/apps/web/src/app/[locale]/[...path]/page.tsx b/apps/web/src/app/[locale]/(main)/[...path]/page.tsx similarity index 85% rename from apps/web/src/app/[locale]/[...path]/page.tsx rename to apps/web/src/app/[locale]/(main)/[...path]/page.tsx index 9efd0f27..1483c96a 100644 --- a/apps/web/src/app/[locale]/[...path]/page.tsx +++ b/apps/web/src/app/[locale]/(main)/[...path]/page.tsx @@ -3,17 +3,17 @@ import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import type { Page as CMSPage } from "@tietokilta/cms-types/payload"; import { Card } from "@tietokilta/ui"; -import { AdminBar } from "../../../components/admin-bar"; -import { LexicalSerializer } from "../../../components/lexical/lexical-serializer"; -import { TableOfContents } from "../../../components/table-of-contents"; -import { fetchPage } from "../../../lib/api/pages"; -import { getCurrentLocale, type Locale } from "../../../locales/server"; -import EventsPage from "../../../custom-pages/events-page"; -import AllEventsPage from "../../../custom-pages/all-events-page"; -import WeeklyNewsletterPage from "../../../custom-pages/weekly-newsletter-page"; -import { generateTocFromRichText } from "../../../lib/utils"; -import WeeklyNewslettersListPage from "../../../custom-pages/weekly-newsletters-list-page"; -import { openGraphImage } from "../../shared-metadata"; +import { AdminBar } from "../../../../components/admin-bar"; +import { LexicalSerializer } from "../../../../components/lexical/lexical-serializer"; +import { TableOfContents } from "../../../../components/table-of-contents"; +import { fetchPage } from "../../../../lib/api/pages"; +import { getCurrentLocale, type Locale } from "../../../../locales/server"; +import EventsPage from "../../../../custom-pages/events-page"; +import AllEventsPage from "../../../../custom-pages/all-events-page"; +import WeeklyNewsletterPage from "../../../../custom-pages/weekly-newsletter-page"; +import { generateTocFromRichText } from "../../../../lib/utils"; +import WeeklyNewslettersListPage from "../../../../custom-pages/weekly-newsletters-list-page"; +import { openGraphImage } from "../../../shared-metadata"; interface NextPage> { params: Promise; diff --git a/apps/web/src/app/[locale]/error.tsx b/apps/web/src/app/[locale]/(main)/error.tsx similarity index 97% rename from apps/web/src/app/[locale]/error.tsx rename to apps/web/src/app/[locale]/(main)/error.tsx index ec9c3c24..57f0fb98 100644 --- a/apps/web/src/app/[locale]/error.tsx +++ b/apps/web/src/app/[locale]/(main)/error.tsx @@ -5,7 +5,7 @@ import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../locales/client"; +} from "../../../locales/client"; function Error({ error, diff --git a/apps/web/src/app/[locale]/events/[...ilmomasiinaPath]/page.tsx b/apps/web/src/app/[locale]/(main)/events/[...ilmomasiinaPath]/page.tsx similarity index 100% rename from apps/web/src/app/[locale]/events/[...ilmomasiinaPath]/page.tsx rename to apps/web/src/app/[locale]/(main)/events/[...ilmomasiinaPath]/page.tsx diff --git a/apps/web/src/app/[locale]/events/[slug]/page.tsx b/apps/web/src/app/[locale]/(main)/events/[slug]/page.tsx similarity index 96% rename from apps/web/src/app/[locale]/events/[slug]/page.tsx rename to apps/web/src/app/[locale]/(main)/events/[slug]/page.tsx index 87cfc77a..eca3564e 100644 --- a/apps/web/src/app/[locale]/events/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/(main)/events/[slug]/page.tsx @@ -14,8 +14,8 @@ import { type EventQuestion, type QuotaSignup, type QuestionAnswer, -} from "../../../../lib/api/external/ilmomasiina"; -import { signUp } from "../../../../lib/api/external/ilmomasiina/actions"; +} from "../../../../../lib/api/external/ilmomasiina"; +import { signUp } from "../../../../../lib/api/external/ilmomasiina/actions"; import { cn, formatDateTimeSeconds, @@ -24,12 +24,12 @@ import { formatDatetimeYearOptions, getLocalizedEventTitle, getQuotasWithOpenAndQueue, -} from "../../../../lib/utils"; -import { BackButton } from "../../../../components/back-button"; -import { getCurrentLocale, getScopedI18n } from "../../../../locales/server"; -import { DateTime } from "../../../../components/datetime"; -import { openGraphImage } from "../../../shared-metadata"; -import { remarkI18n } from "../../../../lib/plugins/remark-i18n"; +} from "../../../../../lib/utils"; +import { BackButton } from "../../../../../components/back-button"; +import { getCurrentLocale, getScopedI18n } from "../../../../../locales/server"; +import { DateTime } from "../../../../../components/datetime"; +import { openGraphImage } from "../../../../shared-metadata"; +import { remarkI18n } from "../../../../../lib/plugins/remark-i18n"; import { SignUpButton } from "./signup-button"; async function SignUpText({ diff --git a/apps/web/src/app/[locale]/events/[slug]/signup-button.tsx b/apps/web/src/app/[locale]/(main)/events/[slug]/signup-button.tsx similarity index 90% rename from apps/web/src/app/[locale]/events/[slug]/signup-button.tsx rename to apps/web/src/app/[locale]/(main)/events/[slug]/signup-button.tsx index bd39215e..dce4e4fb 100644 --- a/apps/web/src/app/[locale]/events/[slug]/signup-button.tsx +++ b/apps/web/src/app/[locale]/(main)/events/[slug]/signup-button.tsx @@ -3,7 +3,7 @@ import Form from "next/form"; import { Button, type ButtonProps } from "@tietokilta/ui"; import { useFormStatus } from "react-dom"; -import type { signUp } from "../../../../lib/api/external/ilmomasiina/actions"; +import type { signUp } from "../../../../../lib/api/external/ilmomasiina/actions"; function StatusButton({ disabled, ...props }: ButtonProps) { const { pending } = useFormStatus(); diff --git a/apps/web/src/app/[locale]/global-error.tsx b/apps/web/src/app/[locale]/(main)/global-error.tsx similarity index 98% rename from apps/web/src/app/[locale]/global-error.tsx rename to apps/web/src/app/[locale]/(main)/global-error.tsx index 0e396a58..b5794962 100644 --- a/apps/web/src/app/[locale]/global-error.tsx +++ b/apps/web/src/app/[locale]/(main)/global-error.tsx @@ -5,7 +5,7 @@ import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../locales/client"; +} from "../../../locales/client"; function GlobalError({ error, diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/(main)/layout.tsx similarity index 84% rename from apps/web/src/app/[locale]/layout.tsx rename to apps/web/src/app/[locale]/(main)/layout.tsx index 2946f22c..c94821e2 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/(main)/layout.tsx @@ -2,15 +2,15 @@ import type { Metadata, Viewport } from "next"; // eslint-disable-next-line camelcase -- next/font/google import { Inter, Roboto_Mono } from "next/font/google"; import NextTopLoader from "nextjs-toploader"; -import { Footer } from "../../components/footer"; -import { MainNav } from "../../components/main-nav"; -import { MobileNav } from "../../components/mobile-nav"; -import { SkipLink } from "../../components/skip-link"; -import { cn } from "../../lib/utils"; +import { Footer } from "../../../components/footer"; +import { MainNav } from "../../../components/main-nav"; +import { MobileNav } from "../../../components/mobile-nav"; +import { SkipLink } from "../../../components/skip-link"; +import { cn } from "../../../lib/utils"; import "@tietokilta/ui/global.css"; -import "./globals.css"; -import { type Locale } from "../../locales/server"; -import { DigiCommitteeRecruitmentAlert } from "../../components/digi-committee-recruitment-alert"; +import "../globals.css"; +import { type Locale } from "../../../locales/server"; +import { DigiCommitteeRecruitmentAlert } from "../../../components/digi-committee-recruitment-alert"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); const robotoMono = Roboto_Mono({ diff --git a/apps/web/src/app/[locale]/not-found.tsx b/apps/web/src/app/[locale]/(main)/not-found.tsx similarity index 92% rename from apps/web/src/app/[locale]/not-found.tsx rename to apps/web/src/app/[locale]/(main)/not-found.tsx index 04f4a49c..94e89c3d 100644 --- a/apps/web/src/app/[locale]/not-found.tsx +++ b/apps/web/src/app/[locale]/(main)/not-found.tsx @@ -1,12 +1,12 @@ "use client"; import { Button, Card } from "@tietokilta/ui"; import Link from "next/link"; -import { DinoGame } from "../../components/dino-game"; +import { DinoGame } from "../../../components/dino-game"; import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../locales/client"; +} from "../../../locales/client"; function Page() { const t = useScopedI18n("not-found"); diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/(main)/page.tsx similarity index 86% rename from apps/web/src/app/[locale]/page.tsx rename to apps/web/src/app/[locale]/(main)/page.tsx index 84655f01..edbdf1dc 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/(main)/page.tsx @@ -1,13 +1,13 @@ import type { EditorState } from "@tietokilta/cms-types/lexical"; import type { News, Page as CMSPage } from "@tietokilta/cms-types/payload"; import { type Metadata } from "next"; -import { EventsDisplay } from "../../components/events-display"; -import { Hero, type ImageWithPhotographer } from "../../components/hero"; -import { LexicalSerializer } from "../../components/lexical/lexical-serializer"; -import { fetchLandingPage } from "../../lib/api/landing-page"; -import { AnnouncementCard } from "../../components/announcement-card"; -import { getCurrentLocale } from "../../locales/server"; -import { openGraphImage } from "../shared-metadata"; +import { EventsDisplay } from "../../../components/events-display"; +import { Hero, type ImageWithPhotographer } from "../../../components/hero"; +import { LexicalSerializer } from "../../../components/lexical/lexical-serializer"; +import { fetchLandingPage } from "../../../lib/api/landing-page"; +import { AnnouncementCard } from "../../../components/announcement-card"; +import { getCurrentLocale } from "../../../locales/server"; +import { openGraphImage } from "../../shared-metadata"; function Content({ content }: { content?: EditorState }) { if (!content) return null; diff --git a/apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/page.tsx b/apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/page.tsx similarity index 88% rename from apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/page.tsx rename to apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/page.tsx index b58f3aa7..ae339c0f 100644 --- a/apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/page.tsx +++ b/apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/page.tsx @@ -1,13 +1,16 @@ /* eslint-disable no-nested-ternary -- I like */ import { notFound } from "next/navigation"; -import { getSignup } from "../../../../../lib/api/external/ilmomasiina"; -import { openGraphImage } from "../../../../shared-metadata"; +import { getSignup } from "../../../../../../lib/api/external/ilmomasiina"; +import { openGraphImage } from "../../../../../shared-metadata"; import { deleteSignUpAction, saveSignUpAction, -} from "../../../../../lib/api/external/ilmomasiina/actions"; -import { getCurrentLocale, getScopedI18n } from "../../../../../locales/server"; -import { getLocalizedEventTitle } from "../../../../../lib/utils"; +} from "../../../../../../lib/api/external/ilmomasiina/actions"; +import { + getCurrentLocale, + getScopedI18n, +} from "../../../../../../locales/server"; +import { getLocalizedEventTitle } from "../../../../../../lib/utils"; import { SignupForm } from "./signup-form"; interface PageProps { diff --git a/apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/signup-form.tsx b/apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/signup-form.tsx similarity index 98% rename from apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/signup-form.tsx rename to apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/signup-form.tsx index b1dd85c0..f8e1c848 100644 --- a/apps/web/src/app/[locale]/signups/[signupId]/[signupEditToken]/signup-form.tsx +++ b/apps/web/src/app/[locale]/(main)/signups/[signupId]/[signupEditToken]/signup-form.tsx @@ -20,17 +20,17 @@ import { ilmomasiinaFieldErrors, type IlmomasiinaEvent, type IlmomasiinaSignupInfo, -} from "../../../../../lib/api/external/ilmomasiina"; +} from "../../../../../../lib/api/external/ilmomasiina"; import type { deleteSignUpAction, saveSignUpAction, -} from "../../../../../lib/api/external/ilmomasiina/actions"; +} from "../../../../../../lib/api/external/ilmomasiina/actions"; import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../../../../locales/client"; -import { cn, getLocalizedEventTitle } from "../../../../../lib/utils"; +} from "../../../../../../locales/client"; +import { cn, getLocalizedEventTitle } from "../../../../../../lib/utils"; type FieldErrorI18n = ReturnType; diff --git a/apps/web/src/app/[locale]/signups/not-found.tsx b/apps/web/src/app/[locale]/(main)/signups/not-found.tsx similarity index 92% rename from apps/web/src/app/[locale]/signups/not-found.tsx rename to apps/web/src/app/[locale]/(main)/signups/not-found.tsx index 638c43aa..574ad70f 100644 --- a/apps/web/src/app/[locale]/signups/not-found.tsx +++ b/apps/web/src/app/[locale]/(main)/signups/not-found.tsx @@ -1,12 +1,12 @@ "use client"; import { Button, Card } from "@tietokilta/ui"; import Link from "next/link"; -import { DinoGame } from "../../../components/dino-game"; +import { DinoGame } from "../../../../components/dino-game"; import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../../locales/client"; +} from "../../../../locales/client"; function Page() { const t = useScopedI18n("not-found"); diff --git a/apps/web/src/app/[locale]/tapahtumat/[...ilmomasiinaPath]/page.tsx b/apps/web/src/app/[locale]/(main)/tapahtumat/[...ilmomasiinaPath]/page.tsx similarity index 100% rename from apps/web/src/app/[locale]/tapahtumat/[...ilmomasiinaPath]/page.tsx rename to apps/web/src/app/[locale]/(main)/tapahtumat/[...ilmomasiinaPath]/page.tsx diff --git a/apps/web/src/app/[locale]/tapahtumat/[slug]/page.tsx b/apps/web/src/app/[locale]/(main)/tapahtumat/[slug]/page.tsx similarity index 100% rename from apps/web/src/app/[locale]/tapahtumat/[slug]/page.tsx rename to apps/web/src/app/[locale]/(main)/tapahtumat/[slug]/page.tsx diff --git a/apps/web/src/app/[locale]/tapahtumat/not-found.tsx b/apps/web/src/app/[locale]/(main)/tapahtumat/not-found.tsx similarity index 92% rename from apps/web/src/app/[locale]/tapahtumat/not-found.tsx rename to apps/web/src/app/[locale]/(main)/tapahtumat/not-found.tsx index 3e390caf..f9f9df6d 100644 --- a/apps/web/src/app/[locale]/tapahtumat/not-found.tsx +++ b/apps/web/src/app/[locale]/(main)/tapahtumat/not-found.tsx @@ -1,12 +1,12 @@ "use client"; import { Button, Card } from "@tietokilta/ui"; import Link from "next/link"; -import { DinoGame } from "../../../components/dino-game"; +import { DinoGame } from "../../../../components/dino-game"; import { I18nProviderClient, useCurrentLocale, useScopedI18n, -} from "../../../locales/client"; +} from "../../../../locales/client"; function Page() { const t = useScopedI18n("not-found"); diff --git a/apps/web/src/app/[locale]/viikkotiedotteet/[slug]/page.tsx b/apps/web/src/app/[locale]/(main)/viikkotiedotteet/[slug]/page.tsx similarity index 100% rename from apps/web/src/app/[locale]/viikkotiedotteet/[slug]/page.tsx rename to apps/web/src/app/[locale]/(main)/viikkotiedotteet/[slug]/page.tsx diff --git a/apps/web/src/app/[locale]/weekly-newsletters/[slug]/page.tsx b/apps/web/src/app/[locale]/(main)/weekly-newsletters/[slug]/page.tsx similarity index 73% rename from apps/web/src/app/[locale]/weekly-newsletters/[slug]/page.tsx rename to apps/web/src/app/[locale]/(main)/weekly-newsletters/[slug]/page.tsx index af8484db..ac330da3 100644 --- a/apps/web/src/app/[locale]/weekly-newsletters/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/(main)/weekly-newsletters/[slug]/page.tsx @@ -1,4 +1,4 @@ -import WeeklyNewsletterPage from "../../../../custom-pages/weekly-newsletter-page"; +import WeeklyNewsletterPage from "../../../../../custom-pages/weekly-newsletter-page"; interface PageProps { params: Promise<{ diff --git a/apps/web/src/app/next_api/fetch-hsl-stops/route.ts b/apps/web/src/app/next_api/fetch-hsl-stops/route.ts deleted file mode 100644 index e654b766..00000000 --- a/apps/web/src/app/next_api/fetch-hsl-stops/route.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - ApolloQueryResult, - DefaultOptions, - DocumentNode, -} from "@apollo/client"; -import { ApolloClient, gql, InMemoryCache } from "@apollo/client"; -import type { - Arrival, - ArrivalAttribute, - Data, - RenderableStop, - Stop, - StopFromApi, - StopOutData, - StopTime, - StopType, -} from "../../../lib/types/hsl-helper-types.ts"; - -const STOPS = [ - // Metro east and west - ["HSL:2222603", "HSL:2222604"], - // Raide jokeri east and west - ["HSL:2222406", "HSL:2222405"], - // Aalto Yliopisto bus stop "east" and "west" - ["HSL:2222234", "HSL:2222212"], -] as const; -const N_ARRIVALS = 6; - -export const dynamic = "force-dynamic"; - -export async function GET() { - const stops = await Promise.all(STOPS.map(getStop)); - - const dataFromHsl: RenderableStop[] = stops.filter( - (stop: T | null): stop is T => stop !== null, - ); - - const retData = { - type: "Data", - data: dataFromHsl, - }; - - return Response.json( - { retData }, - { - status: 200, - headers: { - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache, no-store", - Expires: "0", - }, - }, - ); -} - -const GetStopSchedule = (StopId: string): DocumentNode => - gql(` - { - stop(id: "${StopId}") { - name - stoptimesWithoutPatterns { - realtimeArrival - realtimeArrival - serviceDay - headsign - trip{ - routeShortName - } - } - } - } -`); - -function pad(number: number, size: number) { - let s = String(number); - while (s.length < (size || 2)) { - s = "0".concat(s); - } - return s; -} - -function removeSubstring(fullString: string): string { - const subStrings: string[] = [ - " via Leppävaara", - " via Rautatientori", - " via Tapiola (M)", - " via Huopalahti as.", - " via Tapiola", - " via Pasila as.", - ]; - let str = fullString; - for (const subString of subStrings) { - // HSL sometimes has a bug where HEadSign is null so this handles case string in is null :D - if (str) { - str = str.replace(subString, ""); - } else { - return "Null"; - } - } - return str; -} - -function isTram(arrival: Arrival): boolean { - return arrival.route.includes("15"); -} -function isMetro(arrival: Arrival): boolean { - return arrival.route.includes("M"); -} -function getType(arrivals: Arrival[]): StopType { - if (arrivals.length === 0) return null; - const arrival = arrivals[0]; - if (isMetro(arrival)) { - return "metro"; - } else if (isTram(arrival)) { - return "tram"; - } - return "bus"; -} -function toOutData(stop: Stop | null): StopOutData | null { - if (!stop) return null; - return { - name: stop.name, - type: stop.type, - arrival: stop.stoptimesWithoutPatterns.map((arr: StopTime) => ({ - route: arr.trip.routeShortName, - headSign: arr.headsign, - realTimeArrival: arr.realtimeArrival + arr.serviceDay, - serviceDay: arr.serviceDay, - })), - }; -} - -const getData = async (stop: string): Promise => { - const defaultOptions: DefaultOptions = { - query: { - fetchPolicy: "no-cache", - }, - }; - - const client = new ApolloClient({ - uri: "https://api.digitransit.fi/routing/v1/routers/hsl/index/graphql", - cache: new InMemoryCache({ - resultCaching: false, - }), - defaultOptions, - headers: { - "Content-Type": "application/json", - "digitransit-subscription-key": - process.env.DIGITRANSIT_SUBSCRIPTION_KEY ?? "", - }, - }); - let data: StopFromApi | null = null; - - await client - .query({ - query: GetStopSchedule(stop), - }) - .then((result: ApolloQueryResult) => { - data = result.data.stop; - }); - - return data; -}; - -function makePrintTime(arrival: Arrival): string { - const date = new Date(); - const hour = date.getHours(); - const min = date.getMinutes(); - const sec = date.getSeconds(); - const currentTSM = (hour * 60 + min) * 60 + sec; - let arrivalTime = arrival.realTimeArrival - arrival.serviceDay; - if (arrivalTime / 3600 >= 24) { - arrivalTime -= 24 * 3600; - } - if (arrivalTime - currentTSM > 600) { - return `${pad( - Math.floor((arrival.realTimeArrival - arrival.serviceDay) / 60 / 60) % 24, - 2, - )}:${pad( - Math.floor(((arrival.realTimeArrival - arrival.serviceDay) / 60) % 60), - 2, - )}`; - } - const t1 = Math.floor(arrivalTime - currentTSM); - if (t1 < -60) { - return "NaN"; - } - const t = Math.floor(Math.max(t1, 0) / 60); - return t <= 1 ? "~0" : String(t); -} - -const getStop = async ( - stops: readonly [string, string], - n = N_ARRIVALS, -): Promise => { - const [result1, result2] = await Promise.all( - stops.map((stop) => getData(stop).then(toOutData)), - ); - - if (!result1 || !result2) return null; - - const result: StopOutData = { - name: result1.name, - type: getType(result1.arrival), - arrival: result1.arrival - .map((arr) => arr) - .concat(result2.arrival.map((arr) => arr)) - .sort((arr1, arr2) => arr1.realTimeArrival - arr2.realTimeArrival) - .slice(0, n + 1), - }; - const ArrivalsFormatted: ArrivalAttribute[] = result.arrival - .map((arr: Arrival) => { - return { - route: arr.route ? arr.route.replace(" ", "") : "Null", - headSign: removeSubstring(arr.headSign), - hours: - Math.floor((arr.realTimeArrival - arr.serviceDay) / 60 / 60) % 24, - minutes: Math.floor(((arr.realTimeArrival - arr.serviceDay) / 60) % 60), - intTime: arr.realTimeArrival, - fullTime: makePrintTime(arr), - }; - }) - .filter((arr) => arr.fullTime !== "NaN"); - return { - name: result.name, - type: result.type, - arrivals: ArrivalsFormatted, - }; -}; diff --git a/apps/web/src/components/event-card/index.tsx b/apps/web/src/components/event-card/index.tsx index c9d76642..2c099438 100644 --- a/apps/web/src/components/event-card/index.tsx +++ b/apps/web/src/components/event-card/index.tsx @@ -5,6 +5,8 @@ import type { } from "../../lib/api/external/ilmomasiina"; import { cn, + formatDateTime, + formatDateTimeOptions, formatDateYear, formatDateYearOptions, formatDatetimeYear, @@ -17,10 +19,12 @@ async function SignUpText({ startDate, endDate, className, + compact = false, }: { startDate?: string | null; endDate?: string | null; className?: string; + compact?: boolean; }) { const locale = await getCurrentLocale(); const t = await getScopedI18n("ilmomasiina.status"); @@ -39,6 +43,26 @@ async function SignUpText({ ); } + if (compact) { + if (hasStarted && !hasEnded) { + return ( + + {t("Ilmo auki", { + endDate: formatDatetimeYear(endDate, locale), + })} + + ); + } + + return ( + + {t("Ilmo alkaa", { + startDate: formatDatetimeYear(startDate, locale), + })} + + ); + } + if (hasStarted && !hasEnded) { return ( @@ -61,9 +85,11 @@ async function SignUpText({ async function SignupQuotas({ quotas, className, + compact = false, }: { quotas: EventQuota[]; className?: string; + compact?: boolean; }) { const t = await getScopedI18n("ilmomasiina"); const totalSignupCount = quotas.reduce( @@ -74,6 +100,32 @@ async function SignupQuotas({ const isSingleQuota = quotas.length === 1; + // Compact Mode is used on infoscreen + if (compact) { + return ( +
    +
  • + {t("Ilmoittautuneita")}{" "} +
  • + {quotas.map((quota) => ( +
  • + {quota.title}{" "} + {typeof quota.size === "number" ? ( + + {quota.signupCount} / {quota.size} + + ) : ( + {quota.signupCount} + )} +
  • + ))} +
+ ); + } + if (isSingleQuota) { return (
@@ -114,6 +166,67 @@ async function SignupQuotas({ ); } +export async function EventCardCompact({ + event, + showSignup = true, +}: { + event: IlmomasiinaEvent; + showSignup: boolean; +}) { + let showSignupQuotas = true; + const signupStartDate = event.registrationStartDate; + const signupEndDate = event.registrationEndDate; + + if (event.registrationClosed === true || !signupEndDate || !signupStartDate) { + showSignupQuotas = false; + } + + const t = await getScopedI18n("ilmomasiina.path"); + + const locale = await getCurrentLocale(); + return ( +
  • +
    +
    + +

    + {getLocalizedEventTitle(event.title, locale)} + {event.date ? ( + <> + {", "} + + + ) : null} +

    + + + {showSignupQuotas ? ( + + ) : null} +
    + {event.quotas.length > 0 && showSignup ? ( + + ) : null} +
    +
  • + ); +} + export default async function EventCard({ event, }: { diff --git a/apps/web/src/components/infoscreen/events-list/index.tsx b/apps/web/src/components/infoscreen/events-list/index.tsx new file mode 100644 index 00000000..33887e63 --- /dev/null +++ b/apps/web/src/components/infoscreen/events-list/index.tsx @@ -0,0 +1,63 @@ +import { getISOWeek, getISOWeekYear } from "date-fns"; +import { + fetchUpcomingEvents, + type IlmomasiinaEvent, +} from "../../../lib/api/external/ilmomasiina"; +import { getI18n } from "../../../locales/server.ts"; +import { EventCardCompact } from "../../event-card/index.tsx"; + +export default async function EventListInfoscreen({ + showIlmostatus = true, +}: { + showIlmostatus?: boolean; +}) { + const t = await getI18n(); + const eventsResponse = await fetchUpcomingEvents(); + const events = Array.isArray(eventsResponse.data) ? eventsResponse.data : []; + + const upcomingEventsDataByWeek = Object.groupBy( + events, + ({ date, registrationStartDate, registrationEndDate }) => { + const dateToUse = + date ?? registrationStartDate ?? registrationEndDate ?? ""; + return `${getISOWeekYear(dateToUse).toFixed()}-${getISOWeek(dateToUse).toFixed().padStart(2, "0")}`; // YYYY-VV + }, + ); + + return ( +
    +

    + {t("ilmomasiina.Tapahtumat")} +

    +
      + {Object.entries(upcomingEventsDataByWeek) + .filter( + ( + e: [string, IlmomasiinaEvent[] | undefined], + ): e is [string, IlmomasiinaEvent[]] => !!e[1], + ) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([weekYear, eventsInWeek]) => { + return ( +
      + + {t("calendar.Week")} {Number(weekYear.split("-")[1])} + +
      + {eventsInWeek.map((event) => { + return ( + + ); + })} +
      +
      + ); + })} +
    +
    + ); +} diff --git a/apps/web/src/components/infoscreen/hsl-schedules/fetcher.ts b/apps/web/src/components/infoscreen/hsl-schedules/fetcher.ts new file mode 100644 index 00000000..9ddf580e --- /dev/null +++ b/apps/web/src/components/infoscreen/hsl-schedules/fetcher.ts @@ -0,0 +1,168 @@ +import { + ApolloClient, + createHttpLink, + gql, + InMemoryCache, +} from "@apollo/client"; +import { TZDate } from "@date-fns/tz"; +import type { + HSLResponse, + StopHSL, + Stop, + HSLStopTime, + StopType, +} from "../types/hsl-helper-types.ts"; + +interface StopConfig { + stopType: StopType; + stops: [string, string]; +} +const STOPS = [ + // Metro east and west + { stopType: "metro", stops: ["HSL:2222603", "HSL:2222604"] }, + // Raide jokeri east and west + { stopType: "tram", stops: ["HSL:2222406", "HSL:2222405"] }, + // Aalto Yliopisto bus stop "east" and "west" + { stopType: "bus", stops: ["HSL:2222234", "HSL:2222212"] }, +] as const satisfies StopConfig[]; + +// count of arrivals to render +const N_ARRIVALS = 10; + +const client = new ApolloClient({ + link: createHttpLink({ + uri: "https://api.digitransit.fi/routing/v2/hsl/gtfs/v1", + headers: { + "Content-Type": "application/json", + "digitransit-subscription-key": + process.env.DIGITRANSIT_SUBSCRIPTION_KEY ?? "", + }, + fetchOptions: { + next: { + revalidate: 30, + }, + }, + }), + defaultOptions: { + query: { + fetchPolicy: "no-cache", + }, + }, + cache: new InMemoryCache({ + resultCaching: false, + }), + ssrMode: true, +}); + +const getData = async (stop: string) => { + try { + const data = await client + .query({ + // https://api.digitransit.fi/graphiql/hsl/v2/gtfs/v1?query=%257B%250A%2520%2520stop%28id%253A%2520%2522HSL%253A2222234%2522%29%2520%257B%250A%2520%2520%2520%2520name%250A%2520%2520%2520%2520stoptimesWithoutPatterns%2520%257B%250A%2520%2520%2520%2520%2520%2520realtimeArrival%250A%2520%2520%2520%2520%2520%2520serviceDay%250A%2520%2520%2520%2520%2520%2520trip%2520%257B%250A%2520%2520%2520%2520%2520%2520%2520%2520tripHeadsign%250A%2520%2520%2520%2520%2520%2520%2520%2520routeShortName%250A%2520%2520%2520%2520%2520%2520%257D%250A%2520%2520%2520%2520%257D%250A%2520%2520%257D%250A%257D + query: gql(` + { + stop(id: "${stop}") { + name + stoptimesWithoutPatterns { + realtimeArrival + serviceDay + trip{ + tripHeadsign + routeShortName + } + } + } + } +`), + }) + .then((result) => { + return mapStop(result.data.stop); + }); + return data; + } catch (e) { + // eslint-disable-next-line no-console -- TODO: add actual logger + console.error(e); + return null; + } +}; + +export async function HSLSchedules() { + const stops = await Promise.all(STOPS.map(getStop)); + return stops.filter((f) => f !== null); +} + +function pad(number: number, size: number) { + let s = String(number); + while (s.length < (size || 2)) { + s = "0".concat(s); + } + return s; +} + +function mapStop(stop: StopHSL): Omit { + return { + name: stop.name, + arrivals: stop.stoptimesWithoutPatterns + .map((arr: HSLStopTime) => { + const route = arr.trip.routeShortName; + const headSign = arr.trip.tripHeadsign; + const arrivalTimeLocal = arr.realtimeArrival + arr.serviceDay; + const serviceDay = arr.serviceDay; + const fullTime = makePrintTime(arrivalTimeLocal, serviceDay); + if (!fullTime) { + return null; + } + return { + arrivalTimeUnix: arrivalTimeLocal, + serviceDay, + route: route ? route.replace(" ", "") : "Null", + headSign: headSign || "Null", + hours: Math.floor((arrivalTimeLocal - arr.serviceDay) / 60 / 60) % 24, + minutes: Math.floor(((arrivalTimeLocal - arr.serviceDay) / 60) % 60), + realtimeArrival: arrivalTimeLocal, + fullTime, + }; + }) + .filter((arr) => arr !== null), + }; +} + +function makePrintTime( + arrivalTimeUnix: number, + serviceDay: number, +): string | null { + const date = new TZDate(new Date(), "Europe/Helsinki"); + const hour = date.getHours(); + const min = date.getMinutes(); + const sec = date.getSeconds(); + const secondsFromMidnight = (hour * 60 + min) * 60 + sec; + const arrivalTime = arrivalTimeUnix - serviceDay; + if (arrivalTime - secondsFromMidnight > 600) { + return `${pad( + Math.floor((arrivalTimeUnix - serviceDay) / 60 / 60) % 24, + 2, + )}:${pad(Math.floor(((arrivalTimeUnix - serviceDay) / 60) % 60), 2)}`; + } + const t1 = Math.floor(arrivalTime - secondsFromMidnight); + if (t1 < -60) { + return null; + } + const t = Math.floor(Math.max(t1, 0) / 60); + return t <= 1 ? "~0" : String(t); +} + +const getStop = async ({ stopType, stops }: StopConfig) => { + const [result1, result2] = await Promise.all(stops.map(getData)); + + if (!result1 || !result2) return null; + + const result: Stop = { + name: result1.name, + type: stopType, + arrivals: result1.arrivals + .concat(result2.arrivals) + .sort((arr1, arr2) => arr1.arrivalTimeUnix - arr2.arrivalTimeUnix) + .slice(0, N_ARRIVALS), + }; + return result; +}; diff --git a/apps/web/src/components/infoscreen/hsl-schedules/index.tsx b/apps/web/src/components/infoscreen/hsl-schedules/index.tsx new file mode 100644 index 00000000..6701161f --- /dev/null +++ b/apps/web/src/components/infoscreen/hsl-schedules/index.tsx @@ -0,0 +1,85 @@ +import { getScopedI18n } from "../../../locales/server.ts"; +import type { Stop, StopType } from "../types/hsl-helper-types.ts"; +import { HSLSchedules } from "./fetcher.ts"; + +const getColor = (type: StopType): string => { + switch (type) { + case "metro": + return "#ca4000"; + case "tram": + return "#007e79"; + case "bus": + return "#007ac9"; + } +}; + +const stopName = async (type: StopType) => { + const t = await getScopedI18n("infoscreen"); + switch (type) { + case "metro": + return t("Metro"); + case "tram": + return t("Raide-Jokeri"); + case "bus": + return t("Bussit"); + } +}; +interface HSLScheduleProps { + stop: Stop; + className: string; +} + +function HSLSchedule({ stop }: HSLScheduleProps) { + return ( +
    +

    + {stopName(stop.type)} +

    +
      + {stop.arrivals.map((arr) => ( +
    • +
      + {arr.route} +
      +
      {arr.headSign}
      +
      {arr.fullTime}
      +
    • + ))} +
    +
    + ); +} + +export async function HSLcombinedSchedule() { + const stopData = await HSLSchedules(); + + if (stopData.length === 0) { + return null; + } + return ( +
    +
    +

    + Aalto-yliopisto (M) +

    +
    +
    + {stopData.map((stop) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/infoscreen/infoscreen-header/index.tsx b/apps/web/src/components/infoscreen/infoscreen-header/index.tsx new file mode 100644 index 00000000..75d3592d --- /dev/null +++ b/apps/web/src/components/infoscreen/infoscreen-header/index.tsx @@ -0,0 +1,44 @@ +"use client"; +import React from "react"; +import dynamic from "next/dynamic"; +import Image from "next/image"; +import TiKLogo from "../../../assets/TiK-logo-white.png"; + +const Clock = dynamic(() => import("react-live-clock"), { ssr: false }); + +export function InfoScreenHeader() { + return ( +
    + Tietokilta +
    + + +
    +
    + ); +} +export default InfoScreenHeader; diff --git a/apps/web/src/components/infoscreen/infoscreen-switcher/index.tsx b/apps/web/src/components/infoscreen/infoscreen-switcher/index.tsx new file mode 100644 index 00000000..4f5381c4 --- /dev/null +++ b/apps/web/src/components/infoscreen/infoscreen-switcher/index.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function InfoScreenSwitcher({ + children, +}: { + children: React.ReactNode; +}) { + const [current, setCurrent] = useState(0); + const childrenArray = React.Children.toArray(children).filter(Boolean); + const count = childrenArray.length; + const router = useRouter(); + + useEffect(() => { + const setNextChild = () => { + setCurrent((prev) => (prev + 1) % count); + router.refresh(); + }; + + const intervalId = setInterval(setNextChild, 15000); // Change screen every x seconds + + // Clear the interval when the component unmounts + return () => { + clearInterval(intervalId); + }; + }, [count, router]); + if (childrenArray.length === 0) { + return ( +
    + error, no info screen components functional +
    + ); + } + + return
    {childrenArray[current]}
    ; +} diff --git a/apps/web/src/components/infoscreen/kanttiinit/fetcher.ts b/apps/web/src/components/infoscreen/kanttiinit/fetcher.ts new file mode 100644 index 00000000..086e1cf4 --- /dev/null +++ b/apps/web/src/components/infoscreen/kanttiinit/fetcher.ts @@ -0,0 +1,141 @@ +import type { Locale } from "../../../locales/server"; +import type { + DayMenu, + Food, + OpeningHour, + Restaurant, + RestaurantMenu, + RestaurantMenuLite, +} from "../types/kanttiinit-types"; + +interface RestaurantResponse { + openingHours: OpeningHour[]; + id: number; + type: string; + url: string; + latitude: number; + longitude: number; + address: string; + priceCategory: string; + name: string; +} + +// Single day of meals in Kanttiinit API, key is date +type DayMenuResponse = Record; + +// Single restaurant menu in Kanttiinit API, key is restaurant id +type RestaurantMenuResponse = Record; + +async function KanttiinitRestaurants(locale?: Locale) { + const response: Response = await fetch( + `https://kitchen.kanttiinit.fi/restaurants?lang=${locale ?? "fi"}&ids=2,7,52&priceCategories=student`, + { next: { revalidate: 3600 } }, // fetch only once per hour + ); + + if (response.status !== 200) { + return []; + } + const responseBody = (await response.json()) as RestaurantResponse[]; + + const data: Restaurant[] = responseBody.map( + (restaurant: RestaurantResponse) => { + return { + id: restaurant.id, + name: restaurant.name, + type: restaurant.type, + url: restaurant.url, + opening_hours: restaurant.openingHours, + }; + }, + ); + + return data; +} + +async function KanttiinitMenus(locale?: Locale) { + const ids = [2, 7, 52]; + const today = new Date().toISOString().split("T")[0]; + const response: Response = await fetch( + `https://kitchen.kanttiinit.fi/menus?lang=${locale ?? "fi"}&${ids.join(",")}&days=${today}`, + { next: { revalidate: 3600 } }, // fetch only once per hour + ); + + const responseBody = (await response.json()) as RestaurantMenuResponse; + + const data = Object.entries(responseBody).map( + ([restaurantID, menuResponse]) => { + const parsedMenu = Object.entries(menuResponse).map(([date, foods]) => { + const parsedFoods = Object.entries(foods).map( + ([mealID, meal]) => + ({ + id: parseInt(mealID), + title: meal.title, + properties: meal.properties, + }) as Food, + ); + return { + date, + foods: parsedFoods, + } as DayMenu; + }); + return { + restaurantID: parseInt(restaurantID), + menus: parsedMenu, + } as RestaurantMenuLite; + }, + ); + + return data; +} + +export const fetchMenus = async (locale: Locale): Promise => { + try { + const restaurants = await KanttiinitRestaurants(locale); + const menus = await KanttiinitMenus(locale); + const newMenus: RestaurantMenu[] = restaurants.map( + (restaurant: Restaurant) => { + return { + restaurant, + menus: menus + .filter( + (menu: RestaurantMenuLite) => menu.restaurantID === restaurant.id, + ) + .flatMap((menu) => + menu.menus.map((dayMenu) => { + return { + date: dayMenu.date, + foods: dayMenu.foods + .map((food) => { + if ( + !/chef´s Kitchen|erikoisannos|jälkiruoka|wicked rabbit/i.test( + food.title, + ) + ) { + if (food.title.includes(":")) { + return { + id: food.id, + title: food.title.replace(/^(?:.*?): /, ""), + properties: food.properties, + }; + } + return { + id: food.id, + title: food.title, + properties: food.properties, + }; + } + return undefined; + }) + .filter((food) => food !== undefined), + }; + }), + ), + }; + }, + ); + return newMenus; + } catch (_err: unknown) { + // Error handling can be added here + } + return []; +}; diff --git a/apps/web/src/components/infoscreen/kanttiinit/index.tsx b/apps/web/src/components/infoscreen/kanttiinit/index.tsx new file mode 100644 index 00000000..37fc0a52 --- /dev/null +++ b/apps/web/src/components/infoscreen/kanttiinit/index.tsx @@ -0,0 +1,44 @@ +import { getCurrentLocale, getScopedI18n } from "../../../locales/server"; +import type { Food } from "../types/kanttiinit-types"; +import { fetchMenus } from "./fetcher"; + +export async function KanttiinitCombined() { + const locale = await getCurrentLocale(); + const className = `shadow-solid shadow-black font-bold text-l rounded-md border-2 border-black p-3 font-mono text-gray-900 md:items-center`; + const menus = await fetchMenus(locale); + if (menus.length === 0) { + return null; + } + const t = await getScopedI18n("infoscreen"); + + return ( +
    +
    +

    + {t("Ruokalistat")} +

    +
    +
    + {menus.map((menu) => ( +
    +
      +
    • + {menu.restaurant.name} +
    • + {menu.menus.map((dayMenu) => + dayMenu.foods.map((food: Food, _) => ( +
    • + {food.title} +
    • + )), + )} +
    +
    + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/infoscreen/types/hsl-helper-types.ts b/apps/web/src/components/infoscreen/types/hsl-helper-types.ts new file mode 100644 index 00000000..be81e236 --- /dev/null +++ b/apps/web/src/components/infoscreen/types/hsl-helper-types.ts @@ -0,0 +1,32 @@ +export interface HSLResponse { + stop: StopHSL; +} +export interface StopHSL { + name: string; + stoptimesWithoutPatterns: HSLStopTime[]; +} + +export interface HSLStopTime { + realtimeArrival: number; + serviceDay: number; + trip: { + tripHeadsign: string; + routeShortName: string; + }; +} +export type StopType = "metro" | "tram" | "bus"; + +export interface Arrival { + route: string; + headSign: string; + arrivalTimeUnix: number; + serviceDay: number; + hours: number; + minutes: number; + fullTime: string; +} +export interface Stop { + name: string; + type: StopType; + arrivals: Arrival[]; +} diff --git a/apps/web/src/components/infoscreen/types/kanttiinit-types.ts b/apps/web/src/components/infoscreen/types/kanttiinit-types.ts new file mode 100644 index 00000000..a10ec1cd --- /dev/null +++ b/apps/web/src/components/infoscreen/types/kanttiinit-types.ts @@ -0,0 +1,36 @@ +// Restaurant +export interface Restaurant { + opening_hours: OpeningHour[]; + id: number; + type: string; + url: string; + name: string; +} + +// Helper type for opening hours +export type OpeningHour = string | Date | null; + +// Single food item in a menu +export interface Food { + id: number; + title: string; + properties: string[]; +} + +// Container for meals in a single day +export interface DayMenu { + date: string; + foods: Food[]; +} + +// Container for menus of a single restaurant +export interface RestaurantMenuLite { + restaurantID: number; + menus: DayMenu[]; +} + +// Container for menus of a single restaurant (with restaurant info) +export interface RestaurantMenu { + restaurant: Restaurant; + menus: DayMenu[]; +} diff --git a/apps/web/src/lib/types/hsl-helper-types.ts b/apps/web/src/lib/types/hsl-helper-types.ts deleted file mode 100644 index e7e5d0bf..00000000 --- a/apps/web/src/lib/types/hsl-helper-types.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface Trip { - routeShortName: string; -} -export interface StopFromApi { - __typename: string; - name: string; - stopTimesWithoutPatterns: []; -} -export interface Data { - stop: StopFromApi; -} -export interface HSLResponse { - data: Data; - loading: false; - networkStatus: number; -} -export interface StopTime { - realtimeArrival: number; - serviceDay: number; - headsign: string; - trip: Trip; -} -export interface Stop { - name: string; - type: StopType; - stoptimesWithoutPatterns: StopTime[]; -} -export type StopType = "metro" | "tram" | "bus" | null; - -export interface Arrival { - route: string; - headSign: string; - realTimeArrival: number; - serviceDay: number; -} -export interface StopOutData { - name: string; - type: StopType; - arrival: Arrival[]; -} - -export interface ArrivalAttribute { - route: string; - headSign: string; - hours: number; - minutes: number; - fullTime: string; -} -export interface RenderableStop { - name: string; - type: StopType; - arrivals: ArrivalAttribute[]; -} diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts index ca15a104..ae5d3f31 100644 --- a/apps/web/src/locales/en.ts +++ b/apps/web/src/locales/en.ts @@ -41,6 +41,10 @@ const en = { "generic.Version": "Version", "heading.Main navigation": "Main navigation", "heading.Upcoming events": "Upcoming events", + "infoscreen.Ruokalistat": "Menus", + "infoscreen.Bussit": "Buses", + "infoscreen.Raide-Jokeri": "Raide-Jokeri (Tram)", + "infoscreen.Metro": "Metro", "invoicegenerator.Invoicer name": "Invoicer name", "invoicegenerator.Invoicer email": "Invoicer email", "invoicegenerator.Phone number": "Phone number", @@ -127,8 +131,10 @@ const en = { "ilmomasiina.all-events.Kaikki tapahtumat": "All events", "ilmomasiina.status.Ei ilmoittautuneita vielä": "No sign ups yet.", "ilmomasiina.status.Ilmoittautuminen alkaa": "Sign ups start on {startDate}", + "ilmomasiina.status.Ilmo alkaa": "Sign ups start on {startDate}", "ilmomasiina.status.Ilmoittautuminen auki": "Open for sign ups until {endDate}", + "ilmomasiina.status.Ilmo auki": "Open for sign ups until {endDate}", "ilmomasiina.status.Ilmoittautuminen on päättynyt": "Sign ups have ended", "ilmomasiina.status.Ilmoittautumistiedot eivät ole julkisia": "Sign up information is not public", diff --git a/apps/web/src/locales/fi.ts b/apps/web/src/locales/fi.ts index 44757c47..aedaeaa9 100644 --- a/apps/web/src/locales/fi.ts +++ b/apps/web/src/locales/fi.ts @@ -41,6 +41,10 @@ const fi = { "generic.Version": "Versio", "heading.Main navigation": "Päävalikko", "heading.Upcoming events": "Tulevat tapahtumat", + "infoscreen.Ruokalistat": "Ruokalistat", + "infoscreen.Bussit": "Bussit", + "infoscreen.Raide-Jokeri": "Raide-Jokeri", + "infoscreen.Metro": "Metro", "invoicegenerator.Invoicer name": "Laskuttajan nimi", "invoicegenerator.Invoicer email": "Laskuttajan sähköpostiosoite", "invoicegenerator.Phone number": "Puhelinnumero", @@ -128,8 +132,10 @@ const fi = { "ilmomasiina.status.Ei ilmoittautuneita vielä": "Ei ilmoittautuneita vielä.", "ilmomasiina.status.Ilmoittautuminen alkaa": "Ilmoittautuminen alkaa {startDate}", + "ilmomasiina.status.Ilmo alkaa": "Ilmo alkaa {startDate}", "ilmomasiina.status.Ilmoittautuminen auki": "Ilmoittautuminen auki {endDate} asti", + "ilmomasiina.status.Ilmo auki": "Ilmo auki {endDate} asti", "ilmomasiina.status.Ilmoittautuminen on päättynyt": "Ilmoittautuminen on päättynyt", "ilmomasiina.status.Ilmoittautumistiedot eivät ole julkisia": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8950c2f3..1a7cbf86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: react-dom: specifier: catalog:react19 version: 19.0.0(react@19.0.0) + react-live-clock: + specifier: ^6.1.22 + version: 6.1.24(react-moment@1.1.3(moment@2.30.1)(prop-types@15.8.1)(react@19.0.0))(react@19.0.0) react-markdown: specifier: ^9.0.1 version: 9.0.1(react@19.0.0)(types-react@19.0.0-rc.1) @@ -3074,9 +3077,6 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -3116,9 +3116,6 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - '@types/node@22.10.2': - resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} - '@types/node@22.10.5': resolution: {integrity: sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==} @@ -4027,10 +4024,6 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6823,10 +6816,6 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.49: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} @@ -7103,12 +7092,25 @@ packages: react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + react-live-clock@6.1.24: + resolution: {integrity: sha512-mmlTvtLv69Bbb3diFOZ2UoEPv9DkDnYVQPlClL/FZAGqu289S2QLwLdNmNX4A2m9OiW+JrpUgoLMZCR86um10g==} + peerDependencies: + react: ^16.14.0 || ^17 || ^18 + react-moment: 1.1.3 + react-markdown@9.0.1: resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: '@types/react': npm:types-react@19.0.0-rc.1 react: '>=18' + react-moment@1.1.3: + resolution: {integrity: sha512-8EPvlUL8u6EknPp1ISF5MQ3wx2OHJVXIP/iZc4wRh3iV3XozftZERDv9ANZeAtMlhNNQHdFoqcZHFUkBSTONfA==} + peerDependencies: + moment: ^2.29.0 + prop-types: ^15.7.0 + react: ^16.0 || ^17.0.0 || ^18.0.0 + react-onclickoutside@6.13.1: resolution: {integrity: sha512-LdrrxK/Yh9zbBQdFbMTXPp3dTSN9B+9YJQucdDu3JNKRrbdU+H+/TVONJoWtOwy4II8Sqf1y/DTI6w/vGPYW0w==} peerDependencies: @@ -11453,8 +11455,6 @@ snapshots: dependencies: '@types/estree': 1.0.6 - '@types/estree@1.0.5': {} - '@types/estree@1.0.6': {} '@types/express-fileupload@1.5.1': @@ -11498,10 +11498,6 @@ snapshots: '@types/ms@0.7.34': {} - '@types/node@22.10.2': - dependencies: - undici-types: 6.20.0 - '@types/node@22.10.5': dependencies: undici-types: 6.20.0 @@ -11514,7 +11510,7 @@ snapshots: '@types/papaparse@5.3.15': dependencies: - '@types/node': 22.10.2 + '@types/node': 22.10.5 '@types/parse-json@4.0.2': {} @@ -12654,12 +12650,6 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - cross-spawn@7.0.3: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -13392,7 +13382,7 @@ snapshots: eslint-plugin-tailwindcss@3.17.5(tailwindcss@3.4.17): dependencies: fast-glob: 3.3.2 - postcss: 8.4.47 + postcss: 8.4.49 tailwindcss: 3.4.17 eslint-plugin-testing-library@6.2.2(eslint@9.14.0(jiti@1.21.7))(typescript@5.7.3): @@ -16077,12 +16067,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.4.47: - dependencies: - nanoid: 3.3.8 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.4.49: dependencies: nanoid: 3.3.8 @@ -16354,6 +16338,13 @@ snapshots: react-lifecycles-compat@3.0.4: {} + react-live-clock@6.1.24(react-moment@1.1.3(moment@2.30.1)(prop-types@15.8.1)(react@19.0.0))(react@19.0.0): + dependencies: + moment: 2.30.1 + moment-timezone: 0.5.46 + react: 19.0.0 + react-moment: 1.1.3(moment@2.30.1)(prop-types@15.8.1)(react@19.0.0) + react-markdown@9.0.1(react@19.0.0)(types-react@19.0.0-rc.1): dependencies: '@types/hast': 3.0.4 @@ -16371,6 +16362,12 @@ snapshots: transitivePeerDependencies: - supports-color + react-moment@1.1.3(moment@2.30.1)(prop-types@15.8.1)(react@19.0.0): + dependencies: + moment: 2.30.1 + prop-types: 15.8.1 + react: 19.0.0 + react-onclickoutside@6.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -17812,7 +17809,7 @@ snapshots: '@webpack-cli/serve': 1.7.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0)) colorette: 2.0.20 commander: 7.2.0 - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 fastest-levenshtein: 1.0.16 import-local: 3.2.0 interpret: 2.2.0 @@ -17853,7 +17850,7 @@ snapshots: webpack@5.94.0(@swc/core@1.6.1(@swc/helpers@0.5.15))(webpack-cli@4.10.0): dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 '@webassemblyjs/ast': 1.12.1 '@webassemblyjs/wasm-edit': 1.12.1 '@webassemblyjs/wasm-parser': 1.12.1