14 Commits

Author SHA1 Message Date
arif 38a79d598d deine muuuum
CI / build (push) Successful in 9s
CI / lint (push) Successful in 10s
CI / create-and-publish-docker-image (push) Successful in 10s
2026-06-27 23:56:32 +02:00
arif 3ae05df785 dont format index.html
CI / build (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / create-and-publish-docker-image (push) Successful in 11s
2026-06-27 23:23:57 +02:00
arif 57cbdca662 add tracking
CI / build (push) Successful in 9s
CI / lint (push) Failing after 10s
CI / create-and-publish-docker-image (push) Has been skipped
2026-06-27 23:22:24 +02:00
arif 1a5954ad84 update readme
CI / build (push) Successful in 9s
CI / lint (push) Successful in 10s
CI / create-and-publish-docker-image (push) Successful in 10s
2026-06-27 23:16:52 +02:00
arif c5f625f6f7 add build badge
CI / build (push) Successful in 8s
CI / lint (push) Successful in 9s
CI / create-and-publish-docker-image (push) Successful in 10s
2026-06-27 23:13:42 +02:00
arif 2a56faa1d1 fix homeassitant api endpoint
CI / build (push) Successful in 9s
CI / lint (push) Successful in 9s
CI / create-and-publish-docker-image (push) Successful in 11s
2026-06-27 23:02:39 +02:00
arif f9f1757ec0 add proper types to store
CI / build (push) Successful in 9s
CI / lint (push) Successful in 10s
CI / create-and-publish-docker-image (push) Successful in 11s
2026-06-27 22:54:31 +02:00
arif 2979302e64 remove ass
CI / build (push) Successful in 9s
CI / lint (push) Successful in 10s
CI / create-and-publish-docker-image (push) Successful in 11s
2026-06-27 22:28:21 +02:00
arif 8907602cee restart firefox after deploy
CI / build (push) Successful in 9s
CI / lint (push) Successful in 9s
CI / create-and-publish-docker-image (push) Successful in 9s
2026-06-27 22:25:42 +02:00
arif aa1a9956fe use key for ssh and not cring password
CI / build (push) Successful in 15s
CI / lint (push) Successful in 12s
CI / create-and-publish-docker-image (push) Successful in 9s
2026-06-27 21:41:13 +02:00
arif 8c317123bd try and fix ci
CI / build (push) Successful in 14s
CI / lint (push) Successful in 10s
CI / create-and-publish-docker-image (push) Failing after 35s
2026-06-27 21:30:10 +02:00
arif 3bbc18a253 fix deine mum
CI / build (push) Successful in 10s
CI / lint (push) Successful in 11s
CI / create-and-publish-docker-image (push) Failing after 41s
2026-06-27 21:22:46 +02:00
arif 0fd2d05902 add shopping list to flatastic
CI / build (push) Successful in 16s
CI / lint (push) Failing after 11s
CI / create-and-publish-docker-image (push) Has been skipped
2026-06-27 21:20:12 +02:00
arif 24f866f2bc prepare shopping cart feature 2026-06-27 21:08:25 +02:00
15 changed files with 153 additions and 209 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_FLATTASTIC_API_KEY="bqOh7YBZR9fChJo81bCsTinMtGpC5aok" VITE_FLATTASTIC_API_KEY="bqOh7YBZR9fChJo81bCsTinMtGpC5aok"
VITE_HA_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI3N2JmOTk1ODI3MzA0ZWIzOWYwNThjMzQ4YTY3ZDJkYyIsImlhdCI6MTc1NjQ3NTM4OSwiZXhwIjoyMDcxODM1Mzg5fQ.TZZ4SUGlERuIVrhzC_wfCN-qS1wSAKNN9uMMDjkqOgA" VITE_HA_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzYjU0MzYzZTc5ZjA0Yjk3YWQ3NjRiNTgyOWJjMjYxYiIsImlhdCI6MTc4MjU5NTkzNiwiZXhwIjoyMDk3OTU1OTM2fQ.dRYEhySSNjnOCgtr3C9ix3bIxqeH3jHnjK5vsgMaeLE"
+9 -8
View File
@@ -36,8 +36,6 @@ jobs:
VITE_HA_TOKEN: ${{ secrets.VITE_HA_TOKEN }} VITE_HA_TOKEN: ${{ secrets.VITE_HA_TOKEN }}
- name: Build - name: Build
run: bun run build run: bun run build
- name: Write Git-Hash into html
run: ./pipeline/create-git-hash-html.sh
- name: Create Build Artifact - name: Create Build Artifact
uses: christopherhx/gitea-upload-artifact@v4 uses: christopherhx/gitea-upload-artifact@v4
with: with:
@@ -80,18 +78,21 @@ jobs:
- name: Push Docker image - name: Push Docker image
run: docker push git.rivercry.com/wg/monitor-im-flur run: docker push git.rivercry.com/wg/monitor-im-flur
- name: Deploy via SSH - name: Deploy via SSH
uses: appleboy/ssh-action@v0.1.10 uses: appleboy/ssh-action@v1.0.3
with: with:
host: rivercry.com host: rivercry.com
port: 20022 port: 20022
username: docker username: docker
password: ${{ secrets.GARRISON_DOCKER_PASSWORD }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |
cd monitor-im-flur export HYPRLAND_INSTANCE_SIGNATURE=$(hyprctl instances -j | jq '.[0].instance' | tr -d '"')
cd monitor-im-flur || exit 1
git clean -dfx git clean -dfx
git reset --hard HEAD git reset --hard HEAD
git pull git pull
docker-compose pull docker compose pull
docker-compose down docker compose down --remove-orphans || true
docker-compose up -d docker compose up -d
pkill firefox || true
hyprctl dispatch exec 'firefox -kiosk http://localhost:9123'
+6 -131
View File
@@ -1,135 +1,10 @@
## monitor-im-flur # monitor-im-flur
![Build Status](https://git.rivercry.com/wg/monitor-im-flur/badges/master/pipeline.svg) ![build workflow](https://git.rivercry.com/wg/monitor-im-flur/actions/workflows/ci.yml/badge.svg)
Hallway / common-area wall monitor dashboard. A single-page React + Vite app that surfaces useful household + transit + weather + fun data on a passive display.
### Key Features ## Getting Started
* Realtime-ish autorefresh via git commit hash file (`/git-hash.html`) hot-reloads the deployed page when a new build is published. install bun
* Dynamic theming (day / evening / night) based on current time. ```bash
* Public transport departures (KVV) for two nearby stops (IDs 7000044 & 7000045). $ bun run dev
* Weather (current, hourly, daily min/max) via OpenMeteo.
* Flatastic chores integration (tasks + flatmates) with API key.
* Home Assistant readings (tent temperature + humidity) displayed in a faux terminal with rotating shitposts.
* 4:20 easter egg card + Amogus sprite + other playful flourishes.
* Docker / Nginx static deployment image (`git.rivercry.com/wg/monitor-im-flur:latest`).
* Strict linting + formatting (ESLint AirBnB + Prettier + Biome optional).
* Zustand + RTK (toolkit present) state management (currently Zustand in active use).
### Tech Stack
* React 19 + TypeScript + Vite
* Zustand (with devtools) for app stores (weather, kvv, flatastic, home assistant)
* Nginx (static file serving) inside minimal Docker image
* OpenMeteo, Flatastic, KVV, Home Assistant external APIs
* Git hash pipeline script to trigger client selfreloads
### Project Structure (abridged)
``` ```
src/
api/ # External data fetchers
components/ # UI building blocks (Cards, Timetable, Weather, Terminal, etc.)
store/ # Zustand stores encapsulating fetch + state
types/ # Type definitions for external data
pipeline/
create-git-hash-html.sh # Injects current commit hash into dist
Dockerfile # Nginx static hosting
docker-compose.yml # Example runtime service definition
```
### Environment Configuration
Create a `.env` (or `.env.local`) for Vite with the following (only what you need):
```
VITE_FLATTASTIC_API_KEY=your_flatastic_api_key
# (Planned) VITE_HOME_ASSISTANT_TOKEN=your_long_lived_token # NOTE: currently hardcoded see Security section
```
Vite automatically exposes variables prefixed with `VITE_` to the client bundle. Do NOT place secrets without that prefix but remember: anything in the client bundle is public. For sensitive data consider a tiny proxy backend instead of calling APIs directly from the browser.
### Security Notice
`src/api/homeAssistant.ts` currently contains a hardcoded longlived Home Assistant token. This should be refactored before any public deployment:
1. Remove the literal token from the repository.
2. Load it via environment variable during build (`import.meta.env.VITE_HOME_ASSISTANT_TOKEN`) OR
3. Prefer a minimal server proxy so the token never ships to the browser.
### Development
Install dependencies (Bun is inferred from `bun.lock`, but npm/pnpm/yarn also work).
```
bun install
```
Run the dev server (HTTPS support possible with `vite-plugin-mkcert` if certificates are trusted):
```
bun run dev
```
Open: http://localhost:5173
### Lint & Format
```
bun run lint
```
Optionally run Biome (if desired):
```
npx biome check --apply .
```
### Build
```
bun run build
```
Outputs production bundle to `dist/`.
### Docker Image
Build locally:
```
docker build -t monitor-im-flur:local .
```
Run:
```
docker run --rm -p 9123:80 monitor-im-flur:local
```
Or use compose (uses published image):
```
docker compose up -d
```
Visit: http://localhost:9123
### Git Hash AutoReload Mechanism
`pipeline/create-git-hash-html.sh` writes the current commit (`$GITHUB_SHA`) to `dist/git-hash.html` during CI. The dashboard polls `/git-hash.html` every 10s; when the value changes it performs `window.location.reload()`. Ensure your CI runs the script after `vite build` and before creating the Docker image.
Pseudo CI step example:
```
vite build
GITHUB_SHA=$(git rev-parse HEAD) ./pipeline/create-git-hash-html.sh
docker build -t git.rivercry.com/wg/monitor-im-flur:$(git rev-parse --short HEAD) .
```
### Data Sources
* KVV Departures: Public endpoint (JSON) for stop IDs 7000044 / 7000045.
* Weather: OpenMeteo forecast API (lat 49.0094, lon 8.4044, Europe/Berlin TZ).
* Flatastic: Auth via `x-api-key` (user provided).
* Home Assistant: Longlived bearer token (refactor recommended).
### Adding a New Card
1. Create a directory under `src/components/<NewCard>/` with `NewCard.tsx` & optional `style.module.css`.
2. Wrap content with existing `Card` component (`icon` + `name` props).
3. Register inside `Dashboard.tsx` where layout lives (using `CardColumn` / `CardRow`).
### State Management Notes
All data fetching is encapsulated inside zustand store `fetch` methods invoked on an interval within the respective components. Consider centralizing polling or using React Query if complexity grows.
### Potential Improvements / TODO
* Remove hardcoded Home Assistant token.
* Error + loading states (currently optimistic, failures would be silent / console only).
* Retry & backoff strategy for network calls.
* Dark mode override / manual theme toggle.
* Accessibility pass (ARIA, focus management) current dashboard is mostly passive.
* Tests (none yet). Could add Vitest + React Testing Library.
* Switch transit API code to gracefully handle outages (KVV sometimes rate limits).
### License
Add a license file if you plan to share externally (currently unspecified).
### Support / Contact
Internal project (wg). For issues open a ticket on Gitea: https://git.rivercry.com/wg/monitor-im-flur
---
Generated README draft adjust repository paths / badge branch name if different (e.g., replace `master` with your default branch).
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**", "!**/dist"] "includes": ["**", "!**/dist", "!index.html"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@@ -16,7 +16,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "preset": "recommended"
} }
}, },
"javascript": { "javascript": {
+2 -1
View File
@@ -3,7 +3,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>monitor im flur</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "$GITHUB_SHA" > dist/git-hash.html
+2 -2
View File
@@ -7,7 +7,7 @@ async function fetchTentHumidity() {
return; return;
} }
const url = `https://home.rivercry.com/api/states/sensor.third_reality_inc_3rths0224z_luftfeuchtigkeit_2`; const url = `https://home.rivercry.com/api/states/sensor.zelt_humidity`;
const response = await fetch(url, { const response = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
@@ -26,7 +26,7 @@ async function fetchTentTemperature() {
return; return;
} }
const url = `https://home.rivercry.com/api/states/sensor.third_reality_inc_3rths0224z_temperatur_2`; const url = `https://home.rivercry.com/api/states/sensor.zelt_temperature`;
const response = await fetch(url, { const response = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
+1
View File
@@ -19,6 +19,7 @@ const { innerWidth: width, innerHeight: height } = window;
const getImage = (sus: Amogus) => (sus.isImposter ? imposter : amogus); const getImage = (sus: Amogus) => (sus.isImposter ? imposter : amogus);
const makeInitialCrewmates = (): Amogus[] => { const makeInitialCrewmates = (): Amogus[] => {
console.log("innerWidth", innerWidth, "innerHeight", innerHeight);
return [ return [
makeCrewmate(true), makeCrewmate(true),
makeCrewmate(false), makeCrewmate(false),
-35
View File
@@ -16,28 +16,6 @@ import Weather from "@/components/Weather/Weather";
import style from "./style.module.css"; import style from "./style.module.css";
export default function Dashboard() { export default function Dashboard() {
// '/git-hash.html' contains the current git commit hash, check it every 10 seconds and reload if it isn't the same
const [gitHash, setGitHash] = useState("");
useEffect(() => {
const interval = setInterval(async () => {
const response = await fetch("/git-hash.html");
const text = await response.text();
const newHash = text.trim();
console.log("Fetched git hash:", newHash);
if (gitHash === "") {
setGitHash(newHash);
}
if (gitHash !== "" && newHash !== gitHash) {
setGitHash(newHash);
window.location.reload();
}
}, 10000);
return () => clearInterval(interval);
}, [gitHash]);
const schemes = [style.day, style.evening, style.night]; const schemes = [style.day, style.evening, style.night];
const [schemeIndex, setSchemeIndex] = useState(0); const [schemeIndex, setSchemeIndex] = useState(0);
const scheme = schemes[schemeIndex]; const scheme = schemes[schemeIndex];
@@ -83,19 +61,6 @@ export default function Dashboard() {
<Card icon="🍁" name="420"> <Card icon="🍁" name="420">
<FourTwenty /> <FourTwenty />
</Card> </Card>
<Card icon="🌐" name="GCP Dot">
<iframe
src="https://global-mind.org/gcpdot/gcp.html"
title="GCP Dot"
style={{
width: "112",
height: "112",
border: "none",
margin: "0",
scrollbarWidth: "none",
}}
></iframe>
</Card>
</CardRow> </CardRow>
<Card icon="🔔" name="Terminal" active={true}> <Card icon="🔔" name="Terminal" active={true}>
+34 -1
View File
@@ -1,7 +1,11 @@
import classNames from "classnames"; import classNames from "classnames";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFlatasticStore } from "@/store/flatastic"; import { useFlatasticStore } from "@/store/flatastic";
import type { FlatasticChore, FlatasticUser } from "@/types/flatasticChore"; import type {
FlatasticChore,
FlatasticShoppingItem,
FlatasticUser,
} from "@/types/flatasticChore";
import style from "./style.module.css"; import style from "./style.module.css";
function choreItem(chore: FlatasticChore, idToNameMap: Record<number, string>) { function choreItem(chore: FlatasticChore, idToNameMap: Record<number, string>) {
@@ -43,6 +47,9 @@ function choreItem(chore: FlatasticChore, idToNameMap: Record<number, string>) {
export default function Flatastic() { export default function Flatastic() {
const fetchFlatasticData = useFlatasticStore((state) => state.fetch); const fetchFlatasticData = useFlatasticStore((state) => state.fetch);
const flatasticData = useFlatasticStore((state) => state.flatasticData); const flatasticData = useFlatasticStore((state) => state.flatasticData);
console.log("flatasticData", flatasticData);
const chores = (flatasticData?.chores as FlatasticChore[]) || []; const chores = (flatasticData?.chores as FlatasticChore[]) || [];
const regularChores: FlatasticChore[] = []; const regularChores: FlatasticChore[] = [];
const irregularChores: FlatasticChore[] = []; const irregularChores: FlatasticChore[] = [];
@@ -81,12 +88,38 @@ export default function Flatastic() {
return choreItem(chore, idToNameMap); return choreItem(chore, idToNameMap);
}); });
const shoppingList = flatasticData?.shoppingList || [];
const shoppingListRender = shoppingList.map(
(item: FlatasticShoppingItem) => {
return shoppingItem(item);
},
);
const shoppingListContainer = shoppingList.length > 0 && (
<div className={style.shoppingListContainer}>
<h1>Shopping List</h1>
<ul className={style.shoppingList}>{shoppingListRender}</ul>
</div>
);
return ( return (
<div className={style.container}> <div className={style.container}>
<div className={style.choreContainer}>
<h1>Chores</h1> <h1>Chores</h1>
<ul className={style.choreList}> <ul className={style.choreList}>
{[...regularChoresRender, ...irregularChoresRender]} {[...regularChoresRender, ...irregularChoresRender]}
</ul> </ul>
</div> </div>
{shoppingListContainer}
</div>
);
}
function shoppingItem(item: FlatasticShoppingItem) {
return (
<li key={item.id} className={style.shoppingListItem}>
{item.itemName}
</li>
); );
} }
+29
View File
@@ -1,5 +1,34 @@
.container { .container {
padding: 1px 100px 30px 100px; padding: 1px 100px 30px 100px;
display: flex;
flex-direction: row;
}
.choreContainer {
flex: 8;
}
.shoppingListContainer {
flex: 5;
padding-left: 20px;
}
.shoppingList {
display: flex;
flex-direction: column;
gap: 10px;
list-style-type: none;
padding: 10px 0;
}
.shoppingListItem {
padding: 5px 10px;
text-align: left;
border-top: 2px solid white;
border-left: 2px solid white;
border-bottom: 2px solid #828282;
border-right: 2px solid #828282;
} }
.choreList { .choreList {
+41 -10
View File
@@ -1,22 +1,37 @@
import { create } from "zustand"; import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
import Flatastic from "@/api/flatastic"; import Flatastic from "@/api/flatastic";
import type { FlatasticChore, FlatasticUser } from "@/types/flatasticChore"; import type {
FlatasticChore,
FlatasticShoppingItem,
FlatasticUser,
} from "@/types/flatasticChore";
// biome-ignore format: deep interface FlatasticState {
function parseInformationData(data): FlatasticUser[] { flatasticData: {
chores: FlatasticChore[];
users: FlatasticUser[];
shoppingList: FlatasticShoppingItem[];
};
fetch: () => Promise<void>;
}
function parseInformationData(data: {
flatmates: Array<{ id: string; firstName: string }>;
}): FlatasticUser[] {
return data.flatmates.map((user: { id: string; firstName: string }) => ({ return data.flatmates.map((user: { id: string; firstName: string }) => ({
id: user.id as string, id: Number(user.id) as number,
firstName: user.firstName as string, firstName: user.firstName,
})); }));
} }
const useFlatasticStore = create( const useFlatasticStore = create<FlatasticState>()(
devtools( devtools(
(set) => ({ (set) => ({
flatasticData: { flatasticData: {
chores: [] as FlatasticChore[], chores: [] as FlatasticChore[],
users: [] as FlatasticUser[], users: [] as FlatasticUser[],
shoppingList: [] as FlatasticShoppingItem[],
}, },
fetch: async () => { fetch: async () => {
if (!import.meta.env.VITE_FLATTASTIC_API_KEY) { if (!import.meta.env.VITE_FLATTASTIC_API_KEY) {
@@ -25,13 +40,29 @@ const useFlatasticStore = create(
const flatastic = new Flatastic( const flatastic = new Flatastic(
import.meta.env.VITE_FLATTASTIC_API_KEY, import.meta.env.VITE_FLATTASTIC_API_KEY,
); );
const data = await flatastic.getTaskList(); const taskList = await flatastic.getTaskList();
const dataB = await flatastic.getInformation(); const generalInformatiom = await flatastic.getInformation();
const shoppingList = await flatastic.getShoppingList();
const filteredShoppingList = [];
for (const item of shoppingList) {
if (!item.bought) {
const shoppingItem: FlatasticShoppingItem = {
itemName: item.itemName,
bought: item.bought,
id: item.id,
};
filteredShoppingList.push(shoppingItem);
}
}
set({ set({
flatasticData: { flatasticData: {
chores: data as FlatasticChore[], chores: taskList as FlatasticChore[],
users: parseInformationData(dataB), users: parseInformationData(generalInformatiom),
shoppingList:
filteredShoppingList as FlatasticShoppingItem[],
}, },
}); });
}, },
+7 -1
View File
@@ -2,7 +2,13 @@ import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
import { fetchTentHumidity, fetchTentTemperature } from "@/api/homeAssistant"; import { fetchTentHumidity, fetchTentTemperature } from "@/api/homeAssistant";
const useHomeAssistantStore = create( interface HomeAssistantState {
tentTemperature: number;
tentHumidity: number;
fetch: () => Promise<void>;
}
const useHomeAssistantStore = create<HomeAssistantState>()(
devtools( devtools(
(set) => ({ (set) => ({
tentTemperature: 0, tentTemperature: 0,
+7 -1
View File
@@ -8,6 +8,12 @@ interface FlatasticUser {
firstName: string; firstName: string;
} }
interface FlatasticShoppingItem {
itemName: string;
bought: boolean;
id: string;
}
interface FlatasticChore { interface FlatasticChore {
id: number; id: number;
title: string; title: string;
@@ -20,4 +26,4 @@ interface FlatasticChore {
timeLeftNext: number; timeLeftNext: number;
} }
export type { Flatastic, FlatasticChore, FlatasticUser }; export type { Flatastic, FlatasticChore, FlatasticShoppingItem, FlatasticUser };
+7 -8
View File
@@ -14,15 +14,14 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["./src/*"],
"@components/*": ["src/components/*"], "@components/*": ["./src/components/*"],
"@store/*": ["src/store/*"], "@store/*": ["./src/store/*"],
"@api/*": ["src/api/*"], "@api/*": ["./src/api/*"],
"@types/*": ["src/types/*"], "@types/*": ["./src/types/*"],
"@thunks/*": ["src/store/thunks/*"], "@thunks/*": ["./src/store/thunks/*"],
"@slices/*": ["src/store/slices/*"] "@slices/*": ["./src/store/slices/*"]
}, },
/* Linting */ /* Linting */