From b417bea94507d45ee2d59dab564186bfc889cb48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 16:12:43 +0000 Subject: [PATCH] fix: sync beacon/ add-on tree with root and fail CI on drift Fixes #47 The i18n PRs #48-#51 only updated root src/ but the HA add-on builds from beacon/src/, so users were missing those translation changes. Changes: - Synced 24 files from src/ to beacon/src/ using rsync - Added scripts/check-beacon-sync.sh to detect future drift - Added CI check step to fail builds when directories are out of sync This ensures the dual-directory structure documented in CLAUDE.md stays in sync for all future changes. Signed-off-by: Cursor Agent Co-authored-by: Aaron Sachs --- .github/workflows/ci.yml | 3 + beacon/src/App.tsx | 18 ++- beacon/src/api/photos.test.ts | 121 +++++++++++++++ beacon/src/api/photos.ts | 142 ++++++++++-------- beacon/src/components/CountdownWidget.tsx | 18 ++- beacon/src/components/PhotoFrame.tsx | 18 ++- beacon/src/components/Timer.tsx | 73 ++++----- beacon/src/components/WeatherView.tsx | 34 ++--- .../src/components/cards/CardConfigModal.tsx | 12 +- .../src/components/cards/CardPickerModal.tsx | 4 +- beacon/src/components/cards/registry.test.ts | 2 +- beacon/src/components/cards/registry.tsx | 44 +++--- beacon/src/hooks/useNotifications.test.ts | 63 ++++++++ beacon/src/hooks/useNotifications.ts | 14 +- beacon/src/i18n/translations/de.ts | 68 +++++++++ beacon/src/i18n/translations/en.ts | 77 ++++++++++ beacon/src/i18n/translations/es.ts | 68 +++++++++ beacon/src/i18n/translations/fr.ts | 68 +++++++++ beacon/src/i18n/translations/it.ts | 68 +++++++++ beacon/src/i18n/translations/ja.ts | 68 +++++++++ beacon/src/i18n/translations/ko.ts | 68 +++++++++ beacon/src/i18n/translations/nl.ts | 68 +++++++++ beacon/src/i18n/translations/pt.ts | 68 +++++++++ beacon/src/styles/beacon.css | 6 + beacon/src/types/dashboard-cards.ts | 13 +- scripts/check-beacon-sync.sh | 31 ++++ 26 files changed, 1055 insertions(+), 182 deletions(-) create mode 100644 beacon/src/api/photos.test.ts create mode 100644 beacon/src/hooks/useNotifications.test.ts create mode 100755 scripts/check-beacon-sync.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 571e0345..d2f2a727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: - run: npm ci + - name: Check beacon/ sync + run: bash scripts/check-beacon-sync.sh + - name: Type check run: npx tsc --noEmit diff --git a/beacon/src/App.tsx b/beacon/src/App.tsx index ee8a884a..9d49ff63 100644 --- a/beacon/src/App.tsx +++ b/beacon/src/App.tsx @@ -213,7 +213,7 @@ export function App() { ); // Event notifications (browser + HA mobile_app) - useNotifications(events, client, !focusMemberId); + useNotifications(events, client, !focusMemberId, settings.notificationMinutes); // Leaderboard is still a slide-over panel (not a full view); Chores is now // a real full-screen activeView (see PRD: dedicated chores screen). @@ -566,7 +566,7 @@ export function App() { return ( -
+
{focusInvalid && (
{t('app.kidDisplayNotFound')} @@ -575,12 +575,14 @@ export function App() {
)} - {/* Sidebar */} - + {/* Sidebar — hidden entirely in Kiosk Mode (Settings > Display) */} + {!settings.kioskMode && ( + + )} {/* Main content area */}
diff --git a/beacon/src/api/photos.test.ts b/beacon/src/api/photos.test.ts new file mode 100644 index 00000000..3cdca851 --- /dev/null +++ b/beacon/src/api/photos.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getPhotos } from './photos'; +import { haFetch, hasToken } from './ha-rest'; + +vi.mock('./ha-rest', () => ({ + haFetch: vi.fn(), + hasToken: vi.fn(), +})); + +vi.mock('../config', () => ({ + getConfig: () => ({ photo_directory: '/media/beacon/photos' }), +})); + +const mockHaFetch = vi.mocked(haFetch); +const mockHasToken = vi.mocked(hasToken); + +describe('getPhotos', () => { + beforeEach(() => { + mockHaFetch.mockReset(); + mockHasToken.mockReset(); + }); + + it('returns no photos when there is no way to authenticate (not add-on, no token)', async () => { + mockHasToken.mockReturnValue(false); + + const photos = await getPhotos(['local']); + + expect(photos).toEqual([]); + expect(mockHaFetch).not.toHaveBeenCalled(); + }); + + it('browses the configured photo_directory as a media-source:// id, not the raw filesystem path', async () => { + mockHasToken.mockReturnValue(true); + mockHaFetch.mockImplementation(async (path: string) => { + if (path.startsWith('/api/media_source/browse_media/')) { + return { + children: [ + { media_content_id: 'media-source://media_source/local/beacon/photos/a.jpg', title: 'a', media_content_type: 'image/jpeg' }, + ], + }; + } + if (path.startsWith('/api/media_source/resolve_media/')) { + return { url: '/api/media_source/local/signed-a', mime_type: 'image/jpeg' }; + } + throw new Error(`unexpected path: ${path}`); + }); + + const photos = await getPhotos(['local']); + + const browseCall = mockHaFetch.mock.calls.find(([p]) => p.includes('browse_media')); + expect(browseCall?.[0]).toBe( + `/api/media_source/browse_media/${encodeURIComponent('media-source://media_source/local/beacon/photos')}`, + ); + expect(photos).toEqual([ + { url: '/api/media_source/local/signed-a', caption: 'a', source: 'local' }, + ]); + }); + + it('resolves each image to a signed URL instead of hand-building one', async () => { + mockHasToken.mockReturnValue(true); + mockHaFetch.mockImplementation(async (path: string) => { + if (path.includes('browse_media')) { + return { + children: [ + { media_content_id: 'id-1', title: 'one', media_content_type: 'image/jpeg' }, + { media_content_id: 'id-2', title: 'two', media_content_type: 'image/png' }, + ], + }; + } + if (path === `/api/media_source/resolve_media/${encodeURIComponent('id-1')}`) { + return { url: 'https://ha.local/signed-1' }; + } + if (path === `/api/media_source/resolve_media/${encodeURIComponent('id-2')}`) { + return { url: 'https://ha.local/signed-2' }; + } + throw new Error(`unexpected path: ${path}`); + }); + + const photos = await getPhotos(['local']); + + expect(photos.map((p) => p.url).sort()).toEqual([ + 'https://ha.local/signed-1', + 'https://ha.local/signed-2', + ]); + }); + + it('filters out non-image children and drops photos whose resolve_media call fails', async () => { + mockHasToken.mockReturnValue(true); + mockHaFetch.mockImplementation(async (path: string) => { + if (path.includes('browse_media')) { + return { + children: [ + { media_content_id: 'ok', title: 'ok', media_content_type: 'image/jpeg' }, + { media_content_id: 'broken', title: 'broken', media_content_type: 'image/jpeg' }, + { media_content_id: 'folder', title: 'folder', media_content_type: 'directory' }, + ], + }; + } + if (path === `/api/media_source/resolve_media/${encodeURIComponent('ok')}`) { + return { url: 'https://ha.local/ok' }; + } + if (path === `/api/media_source/resolve_media/${encodeURIComponent('broken')}`) { + throw new Error('resolve failed'); + } + throw new Error(`unexpected path: ${path}`); + }); + + const photos = await getPhotos(['local']); + + expect(photos).toEqual([{ url: 'https://ha.local/ok', caption: 'ok', source: 'local' }]); + }); + + it('does not blow up the whole call when browse_media itself fails', async () => { + mockHasToken.mockReturnValue(true); + mockHaFetch.mockRejectedValue(new Error('network error')); + + const photos = await getPhotos(['local']); + + expect(photos).toEqual([]); + }); +}); diff --git a/beacon/src/api/photos.ts b/beacon/src/api/photos.ts index df05db11..09e04814 100644 --- a/beacon/src/api/photos.ts +++ b/beacon/src/api/photos.ts @@ -1,7 +1,8 @@ import { Photo } from '../types/photos'; import { getConfig } from '../config'; +import { haFetch, hasToken } from './ha-rest'; -const { ha_url: HA_URL, ha_token: HA_TOKEN, photo_directory: PHOTOS_PATH } = getConfig(); +const { photo_directory: PHOTOS_PATH } = getConfig(); /** * Shuffles an array in place using Fisher-Yates. @@ -15,90 +16,99 @@ function shuffle(arr: T[]): T[] { } /** - * Fetches photos from HA media browser API. + * Converts a filesystem-style config path (e.g. "/media/beacon/photos") into + * the media-source:// content id browse_media actually expects. HA's local + * media root IS /media, so the id is everything after that prefix. */ -async function fetchHAMediaPhotos(): Promise { - if (!HA_TOKEN) return []; +function toMediaSourceId(path: string): string { + const relative = path.replace(/^\/?media\/?/, '').replace(/\/$/, ''); + return relative + ? `media-source://media_source/local/${relative}` + : 'media-source://media_source/local'; +} - try { - const baseUrl = HA_URL.replace(/\/$/, ''); - const response = await fetch( - `${baseUrl}/api/media_source/browse_media`, - { - headers: { - Authorization: `Bearer ${HA_TOKEN}`, - 'Content-Type': 'application/json', - }, - }, - ); +interface BrowseMediaChild { + media_content_id: string; + title: string; + media_content_type: string; +} - if (!response.ok) return []; - - const data = await response.json(); - const children: Array<{ - media_content_id: string; - title: string; - media_content_type: string; - thumbnail?: string; - }> = data.children || []; - - return children - .filter((item) => item.media_content_type?.startsWith('image')) - .map((item) => ({ - url: `${baseUrl}/api/media_source/local/${item.media_content_id}`, - caption: item.title, - source: 'ha_media' as const, - })); - } catch { - console.warn('Beacon: Failed to fetch HA media photos'); - return []; - } +interface BrowseMediaResponse { + children?: BrowseMediaChild[]; +} + +interface ResolveMediaResponse { + url: string; + mime_type?: string; } /** - * Fetches photos from a local directory served by HA. + * Resolves a media_content_id to a signed, playable URL via HA's + * resolve_media endpoint — the hand-built /api/media_source/local/ URL + * used previously isn't a real HA endpoint. */ -async function fetchLocalPhotos(): Promise { - if (!HA_TOKEN) return []; +async function resolvePhotoUrl(mediaContentId: string): Promise { + try { + const resolved = (await haFetch( + `/api/media_source/resolve_media/${encodeURIComponent(mediaContentId)}`, + )) as ResolveMediaResponse; + return resolved?.url ?? null; + } catch { + return null; + } +} + +async function browseAndResolvePhotos( + mediaContentId: string | null, + source: 'local' | 'ha_media', +): Promise { + if (!hasToken()) return []; try { - const baseUrl = HA_URL.replace(/\/$/, ''); - const response = await fetch( - `${baseUrl}/api/media_source/browse_media?media_content_id=${encodeURIComponent(PHOTOS_PATH)}`, - { - headers: { - Authorization: `Bearer ${HA_TOKEN}`, - 'Content-Type': 'application/json', - }, - }, + const path = mediaContentId + ? `/api/media_source/browse_media/${encodeURIComponent(mediaContentId)}` + : '/api/media_source/browse_media'; + const data = (await haFetch(path)) as BrowseMediaResponse; + const children = data.children || []; + + const images = children.filter((item) => item.media_content_type?.startsWith('image')); + + const resolved = await Promise.all( + images.map(async (item): Promise => { + const url = await resolvePhotoUrl(item.media_content_id); + if (!url) return null; + return { url, caption: item.title, source }; + }), ); - if (!response.ok) return []; - - const data = await response.json(); - const children: Array<{ - media_content_id: string; - title: string; - media_content_type: string; - }> = data.children || []; - - return children - .filter((item) => item.media_content_type?.startsWith('image')) - .map((item) => ({ - url: `${baseUrl}/api/media_source/local/${item.media_content_id}`, - caption: item.title, - source: 'local' as const, - })); + return resolved.filter((photo): photo is Photo => photo !== null); } catch { - console.warn('Beacon: Failed to fetch local photos'); + console.warn(`Beacon: Failed to fetch ${source} photos`); return []; } } +/** + * Fetches photos from HA's media browser root. + */ +async function fetchHAMediaPhotos(): Promise { + return browseAndResolvePhotos(null, 'ha_media'); +} + +/** + * Fetches photos from the configured local directory, served via HA's + * media source (converted from the filesystem-style photo_directory path). + */ +async function fetchLocalPhotos(): Promise { + return browseAndResolvePhotos(toMediaSourceId(PHOTOS_PATH), 'local'); +} + /** * Gets all photos from configured sources, shuffled. */ -export async function getPhotos(sources: Array<'local' | 'google_photos' | 'ha_media'> = ['ha_media', 'local']): Promise { +export async function getPhotos( + sources: Array<'local' | 'google_photos' | 'ha_media'> = ['ha_media', 'local'], +): Promise { const fetchers: Promise[] = []; if (sources.includes('ha_media')) { diff --git a/beacon/src/components/CountdownWidget.tsx b/beacon/src/components/CountdownWidget.tsx index ca82b52c..5bfdfe0f 100644 --- a/beacon/src/components/CountdownWidget.tsx +++ b/beacon/src/components/CountdownWidget.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useCallback } from 'react'; import { differenceInDays, parseISO, startOfDay, format } from 'date-fns'; import { CalendarEvent } from '../types'; +import { useTranslation } from '../i18n'; const STORAGE_KEY = 'beacon-countdown-events'; @@ -29,6 +30,7 @@ function saveSavedCountdowns(items: CountdownEvent[]) { } export function CountdownWidget({ events = [] }: CountdownWidgetProps) { + const { t } = useTranslation(); const [savedCountdowns, setSavedCountdowns] = useState(loadSavedCountdowns); const [showAdd, setShowAdd] = useState(false); const [newTitle, setNewTitle] = useState(''); @@ -94,14 +96,14 @@ export function CountdownWidget({ events = [] }: CountdownWidgetProps) {

- Countdown + {t('countdown.title')}

@@ -112,7 +114,7 @@ export function CountdownWidget({ events = [] }: CountdownWidgetProps) { setNewTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdd()} @@ -125,7 +127,7 @@ export function CountdownWidget({ events = [] }: CountdownWidgetProps) { min={format(new Date(), 'yyyy-MM-dd')} />
)} @@ -140,7 +142,7 @@ export function CountdownWidget({ events = [] }: CountdownWidgetProps) {
{days} - {days === 1 ? 'day' : 'days'} + {days === 1 ? t('countdown.day') : t('countdown.days')}
@@ -154,8 +156,8 @@ export function CountdownWidget({ events = [] }: CountdownWidgetProps) { type="button" className="countdown-remove-btn" onClick={() => handleRemove(c.id)} - title="Remove" - aria-label={`Remove ${c.title}`} + title={t('countdown.remove')} + aria-label={t('countdown.removeNamed', { name: c.title })} > × diff --git a/beacon/src/components/PhotoFrame.tsx b/beacon/src/components/PhotoFrame.tsx index 67e61587..96fae0fd 100644 --- a/beacon/src/components/PhotoFrame.tsx +++ b/beacon/src/components/PhotoFrame.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, } from 'lucide-react'; import { usePhotos } from '../hooks/usePhotos'; +import { useTranslation } from '../i18n'; import { NowPlayingBar } from './NowPlayingBar'; import { MediaPlayer } from '../types/music'; @@ -52,6 +53,7 @@ export function PhotoFrame({ onMusicSetVolume, onBack, }: PhotoFrameProps) { + const { t } = useTranslation(); const { currentPhoto, nextPhoto, @@ -117,9 +119,9 @@ export function PhotoFrame({ return (
-

No photos available

+

{t('photoFrame.noPhotos')}

- Add photos to your Home Assistant media directory + {t('photoFrame.addPhotosHint')}

{onBack && ( @@ -127,7 +129,7 @@ export function PhotoFrame({ type="button" className="photo-frame-back" onClick={(e) => { e.stopPropagation(); onBack(); }} - aria-label="Back to dashboard" + aria-label={t('photoFrame.backToDashboard')} > @@ -143,7 +145,7 @@ export function PhotoFrame({ {currentPhoto?.caption
@@ -164,7 +166,7 @@ export function PhotoFrame({ type="button" className="photo-frame-control-btn" onClick={(e) => { e.stopPropagation(); previousPhoto(); scheduleHide(); }} - aria-label="Previous photo" + aria-label={t('photoFrame.previousPhoto')} > @@ -172,7 +174,7 @@ export function PhotoFrame({ type="button" className="photo-frame-control-btn photo-frame-control-btn--play" onClick={(e) => { e.stopPropagation(); handlePauseToggle(); }} - aria-label={isActive ? 'Pause slideshow' : 'Resume slideshow'} + aria-label={isActive ? t('photoFrame.pauseSlideshow') : t('photoFrame.resumeSlideshow')} > {isActive ? : } @@ -180,7 +182,7 @@ export function PhotoFrame({ type="button" className="photo-frame-control-btn" onClick={(e) => { e.stopPropagation(); nextPhoto(); scheduleHide(); }} - aria-label="Next photo" + aria-label={t('photoFrame.nextPhoto')} > @@ -192,7 +194,7 @@ export function PhotoFrame({ type="button" className="photo-frame-back" onClick={(e) => { e.stopPropagation(); onBack(); }} - aria-label="Back to dashboard" + aria-label={t('photoFrame.backToDashboard')} > diff --git a/beacon/src/components/Timer.tsx b/beacon/src/components/Timer.tsx index 9687f525..79ce0ef0 100644 --- a/beacon/src/components/Timer.tsx +++ b/beacon/src/components/Timer.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Play, Pause, RotateCcw, Flag, X, Plus, Volume2, BellOff } from 'lucide-react'; +import { useTranslation } from '../i18n'; +import type { TranslationKey } from '../i18n/translations/en'; const PRESETS = [ { label: '1m', seconds: 60 }, @@ -14,12 +16,12 @@ type TimerMode = 'stopwatch' | 'timers'; type SoundName = 'beep' | 'chime' | 'alarm' | 'kitchen' | 'gentle'; -const SOUND_OPTIONS: { key: SoundName; label: string }[] = [ - { key: 'chime', label: 'Chime' }, - { key: 'beep', label: 'Beep' }, - { key: 'alarm', label: 'Alarm' }, - { key: 'kitchen', label: 'Kitchen' }, - { key: 'gentle', label: 'Gentle' }, +const SOUND_OPTIONS: { key: SoundName; labelKey: TranslationKey }[] = [ + { key: 'chime', labelKey: 'timer.soundChime' }, + { key: 'beep', labelKey: 'timer.soundBeep' }, + { key: 'alarm', labelKey: 'timer.soundAlarm' }, + { key: 'kitchen', labelKey: 'timer.soundKitchen' }, + { key: 'gentle', labelKey: 'timer.soundGentle' }, ]; const STORAGE_KEY = 'beacon-timer-sound'; @@ -146,6 +148,7 @@ function formatTime(totalMs: number): string { let nextTimerId = 1; export function Timer({ compact = false }: TimerProps) { + const { t } = useTranslation(); const [mode, setMode] = useState('timers'); const [sound, setSound] = useState(getStoredSound); @@ -342,14 +345,14 @@ export function Timer({ compact = false }: TimerProps) { className={`timer-mode-btn ${mode === 'timers' ? 'timer-mode-btn--active' : ''}`} onClick={() => setMode('timers')} > - Timers + {t('timer.modeTimers')}
@@ -367,9 +370,9 @@ export function Timer({ compact = false }: TimerProps) { changeSound(opt.key); playSoundOnce(opt.key); }} - title={`Preview ${opt.label}`} + title={t('timer.previewSound', { name: t(opt.labelKey) })} > - {opt.label} + {t(opt.labelKey)} ))}
@@ -379,7 +382,7 @@ export function Timer({ compact = false }: TimerProps) { setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addTimer(); }} @@ -400,45 +403,45 @@ export function Timer({ compact = false }: TimerProps) { type="button" className="timer-btn timer-btn--play timer-start-btn" onClick={addTimer} - title="Start timer" + title={t('timer.startTimer')} > - Start + {t('timer.start')}
{/* Active timers list */} {timers.length > 0 && (
- {timers.map((t) => { - const remaining = getRemaining(t); + {timers.map((timerItem) => { + const remaining = getRemaining(timerItem); return (
-
{t.name}
-
+
{timerItem.name}
+
{formatTime(remaining)}
- {t.finished && ( + {timerItem.finished && ( )} - {!t.finished && ( - t.running ? ( + {!timerItem.finished && ( + timerItem.running ? ( @@ -446,8 +449,8 @@ export function Timer({ compact = false }: TimerProps) { @@ -456,8 +459,8 @@ export function Timer({ compact = false }: TimerProps) { @@ -482,7 +485,7 @@ export function Timer({ compact = false }: TimerProps) { type="button" className="timer-btn timer-btn--play" onClick={swStart} - title="Start" + title={t('timer.start')} > @@ -491,7 +494,7 @@ export function Timer({ compact = false }: TimerProps) { type="button" className="timer-btn timer-btn--pause" onClick={swPause} - title="Pause" + title={t('timer.pause')} > @@ -502,7 +505,7 @@ export function Timer({ compact = false }: TimerProps) { type="button" className="timer-btn" onClick={swLap} - title="Lap" + title={t('timer.lap')} > @@ -513,7 +516,7 @@ export function Timer({ compact = false }: TimerProps) { type="button" className="timer-btn" onClick={swReset} - title="Reset" + title={t('timer.reset')} > @@ -524,7 +527,7 @@ export function Timer({ compact = false }: TimerProps) {
{laps.map((lap, i) => (
- Lap {i + 1} + {t('timer.lapN', { n: i + 1 })} {formatTime(lap)}
))} diff --git a/beacon/src/components/WeatherView.tsx b/beacon/src/components/WeatherView.tsx index c582ae36..b4a9d0b4 100644 --- a/beacon/src/components/WeatherView.tsx +++ b/beacon/src/components/WeatherView.tsx @@ -67,7 +67,7 @@ export function WeatherView() { const fetchData = useCallback(async () => { if (!hasToken()) { - setError('Not connected to Home Assistant'); + setError(t('weatherView.notConnected')); setLoading(false); return; } @@ -96,7 +96,7 @@ export function WeatherView() { }>; const found = states.find(s => s.entity_id.startsWith('weather.')); if (!found) { - setError('No weather entity found'); + setError(t('weatherView.noEntityFound')); setLoading(false); return; } @@ -136,7 +136,7 @@ export function WeatherView() { // Fetch hourly forecast await fetchHourly(entityId); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch weather'); + setError(err instanceof Error ? err.message : t('weatherView.fetchFailed')); } finally { setLoading(false); } @@ -159,7 +159,7 @@ export function WeatherView() { if (loading && !current) { return (
-
Loading weather data...
+
{t('weatherView.loading')}
); } @@ -170,7 +170,7 @@ export function WeatherView() {

{error}

@@ -202,25 +202,25 @@ export function WeatherView() {
{current.feelsLike != null && (
- Feels like + {t('weatherView.feelsLike')} {Math.round(current.feelsLike)}°{unit}
)} {current.humidity != null && (
- Humidity + {t('weatherView.humidity')} {current.humidity}%
)} {current.windSpeed != null && (
- Wind + {t('weatherView.wind')} {Math.round(current.windSpeed)} mph
)} {current.pressure != null && (
- Pressure + {t('weatherView.pressure')} {Math.round(current.pressure)} hPa
)} @@ -230,8 +230,8 @@ export function WeatherView() { type="button" className="weather-refresh-btn" onClick={fetchData} - title="Refresh weather" - aria-label="Refresh weather" + title={t('weatherView.refreshWeather')} + aria-label={t('weatherView.refreshWeather')} > @@ -240,7 +240,7 @@ export function WeatherView() { {/* 7-day forecast */} {forecast.length > 0 && (
-

7-Day Forecast

+

{t('weatherView.sevenDayForecast')}

{forecast.map((day) => { const date = parseISO(day.datetime); @@ -283,21 +283,21 @@ export function WeatherView() {

- Hourly — {format(parseISO(selectedDay), 'EEEE, MMM d')} + {t('weatherView.hourlyForDay', { day: format(parseISO(selectedDay), 'EEEE, MMM d') })}

{hourlyLoading ? ( -
Loading hourly data...
+
{t('weatherView.loadingHourly')}
) : hoursForSelectedDay.length === 0 ? ( -
No hourly data available for this day.
+
{t('weatherView.noHourlyData')}
) : (
{hoursForSelectedDay.map((hour) => { diff --git a/beacon/src/components/cards/CardConfigModal.tsx b/beacon/src/components/cards/CardConfigModal.tsx index abb10143..34d1758b 100644 --- a/beacon/src/components/cards/CardConfigModal.tsx +++ b/beacon/src/components/cards/CardConfigModal.tsx @@ -3,6 +3,7 @@ import { DashboardCard } from '../../types/dashboard-cards'; import { EntityMultiPicker, EntityPicker } from './EntityPicker'; import { getCardDefinition } from './registry'; import { readString, readStringArray } from './card-config'; +import { useTranslation } from '../../i18n'; interface CardConfigModalProps { card: DashboardCard; @@ -26,6 +27,7 @@ function initialConfig(card: DashboardCard) { /** Generic per-card-type configuration form for advanced dashboard cards. */ export function CardConfigModal({ card, onSave, onClose }: CardConfigModalProps) { + const { t } = useTranslation(); const definition = getCardDefinition(card.type); const fields = definition?.configFields ?? []; const [config, setConfig] = useState>(() => initialConfig(card)); @@ -55,14 +57,14 @@ export function CardConfigModal({ card, onSave, onClose }: CardConfigModalProps) return (
-
{field.label}
- {field.description &&
{field.description}
} +
{t(field.labelKey)}
+ {field.descriptionKey &&
{t(field.descriptionKey)}
}
updateConfig(field.key, event.target.checked)} - aria-label={field.label} + aria-label={t(field.labelKey)} />
); @@ -71,7 +73,7 @@ export function CardConfigModal({ card, onSave, onClose }: CardConfigModalProps) const entityIds = readStringArray(config, field.key); return (
- + toggleEntity(field.key, entityId)} @@ -83,7 +85,7 @@ export function CardConfigModal({ card, onSave, onClose }: CardConfigModalProps) const value = typeof rawValue === 'string' ? rawValue : ''; return (
- + {field.type === 'entity' ? ( def.allowedRegions.includes(region)); return ( @@ -28,7 +30,7 @@ export function CardPickerModal({ region, onPick, onClose }: CardPickerModalProp onClick={() => onPick(def.type)} > {def.icon} - {def.displayName} + {t(def.displayNameKey)} ))} {options.length === 0 && ( diff --git a/beacon/src/components/cards/registry.test.ts b/beacon/src/components/cards/registry.test.ts index 11b83a4d..162b794b 100644 --- a/beacon/src/components/cards/registry.test.ts +++ b/beacon/src/components/cards/registry.test.ts @@ -44,7 +44,7 @@ describe('card registry contract', () => { }); it('resolves known types and ignores unknown ones', () => { - expect(getCardDefinition('family-calendar')?.displayName).toBe('Family Calendar'); + expect(getCardDefinition('family-calendar')?.displayNameKey).toBe('cards.familyCalendar.displayName'); expect(getCardDefinition('nope')).toBeUndefined(); }); }); diff --git a/beacon/src/components/cards/registry.tsx b/beacon/src/components/cards/registry.tsx index d0c40aca..1d7e8a16 100644 --- a/beacon/src/components/cards/registry.tsx +++ b/beacon/src/components/cards/registry.tsx @@ -13,7 +13,7 @@ import { HaToggleCard } from './HaToggleCard'; export const cardRegistry: Record = { 'clock-weather': { type: 'clock-weather', - displayName: 'Clock & Weather', + displayNameKey: 'cards.clockWeather.displayName', icon: '🕐', component: ClockWeatherCard, defaultConfig: {}, @@ -22,7 +22,7 @@ export const cardRegistry: Record = { }, 'family-calendar': { type: 'family-calendar', - displayName: 'Family Calendar', + displayNameKey: 'cards.familyCalendar.displayName', icon: '📅', component: FamilyCalendarCard, defaultConfig: { show_other: true }, @@ -31,15 +31,15 @@ export const cardRegistry: Record = { { type: 'toggle', key: 'show_other', - label: 'Show Other', - description: 'Display events not assigned to a family member', + labelKey: 'cards.familyCalendar.showOtherLabel', + descriptionKey: 'cards.familyCalendar.showOtherDescription', }, ], allowedRegions: ['main'], }, 'agenda-today': { type: 'agenda-today', - displayName: 'Today', + displayNameKey: 'cards.agendaToday.displayName', icon: '📋', component: AgendaTodayCard, defaultConfig: {}, @@ -48,7 +48,7 @@ export const cardRegistry: Record = { }, 'agenda-week': { type: 'agenda-week', - displayName: 'This Week', + displayNameKey: 'cards.agendaWeek.displayName', icon: '🗓️', component: AgendaWeekCard, defaultConfig: {}, @@ -57,7 +57,7 @@ export const cardRegistry: Record = { }, menu: { type: 'menu', - displayName: 'Menu', + displayNameKey: 'cards.menu.displayName', icon: '🍽️', component: MenuCard, defaultConfig: {}, @@ -66,7 +66,7 @@ export const cardRegistry: Record = { }, tasks: { type: 'tasks', - displayName: 'Tasks', + displayNameKey: 'cards.tasks.displayName', icon: '✅', component: TasksCard, defaultConfig: {}, @@ -75,55 +75,55 @@ export const cardRegistry: Record = { }, shopping: { type: 'shopping', - displayName: 'Shopping', + displayNameKey: 'cards.shopping.displayName', icon: '🛒', component: ShoppingCard, defaultConfig: { shoppingEntity: '' }, defaultSize: 'sm', configFields: [ - { type: 'entity', key: 'shoppingEntity', label: 'Shopping List Entity' }, + { type: 'entity', key: 'shoppingEntity', labelKey: 'cards.shopping.entityLabel' }, ], allowedRegions: ['sidebar'], }, 'ha-entity': { type: 'ha-entity', - displayName: 'HA Entity', + displayNameKey: 'cards.haEntity.displayName', icon: '📟', component: HaEntityCard, defaultConfig: { entity_id: '', title: '', subtitle: '' }, defaultSize: 'sm', configFields: [ - { type: 'entity', key: 'entity_id', label: 'Entity' }, - { type: 'text', key: 'title', label: 'Title' }, - { type: 'text', key: 'subtitle', label: 'Subtitle' }, + { type: 'entity', key: 'entity_id', labelKey: 'cards.common.entityLabel' }, + { type: 'text', key: 'title', labelKey: 'cards.common.titleLabel' }, + { type: 'text', key: 'subtitle', labelKey: 'cards.common.subtitleLabel' }, ], allowedRegions: ['topbar', 'sidebar', 'main'], }, 'ha-entities-list': { type: 'ha-entities-list', - displayName: 'HA Entities List', + displayNameKey: 'cards.haEntitiesList.displayName', icon: '📶', component: HaEntitiesListCard, defaultConfig: { title: 'Entities', subtitle: '', entity_ids: [] }, defaultSize: 'sm', configFields: [ - { type: 'text', key: 'title', label: 'Title' }, - { type: 'text', key: 'subtitle', label: 'Subtitle' }, - { type: 'entity-list', key: 'entity_ids', label: 'Entities' }, + { type: 'text', key: 'title', labelKey: 'cards.common.titleLabel' }, + { type: 'text', key: 'subtitle', labelKey: 'cards.common.subtitleLabel' }, + { type: 'entity-list', key: 'entity_ids', labelKey: 'cards.common.entitiesLabel' }, ], allowedRegions: ['topbar', 'sidebar', 'main'], }, 'ha-toggle': { type: 'ha-toggle', - displayName: 'HA Toggle', + displayNameKey: 'cards.haToggle.displayName', icon: '💡', component: HaToggleCard, defaultConfig: { entity_ids: [], title: '', subtitle: '' }, defaultSize: 'sm', configFields: [ - { type: 'entity-list', key: 'entity_ids', label: 'Entities', legacyKey: 'entity_id' }, - { type: 'text', key: 'title', label: 'Title' }, - { type: 'text', key: 'subtitle', label: 'Subtitle' }, + { type: 'entity-list', key: 'entity_ids', labelKey: 'cards.common.entitiesLabel', legacyKey: 'entity_id' }, + { type: 'text', key: 'title', labelKey: 'cards.common.titleLabel' }, + { type: 'text', key: 'subtitle', labelKey: 'cards.common.subtitleLabel' }, ], allowedRegions: ['topbar', 'sidebar', 'main'], }, diff --git a/beacon/src/hooks/useNotifications.test.ts b/beacon/src/hooks/useNotifications.test.ts new file mode 100644 index 00000000..9ae4abdd --- /dev/null +++ b/beacon/src/hooks/useNotifications.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useNotifications } from './useNotifications'; +import type { CalendarEvent } from '../types'; + +function eventIn(minutesFromNow: number, id = 'e1'): CalendarEvent { + const start = new Date(Date.now() + minutesFromNow * 60 * 1000); + const end = new Date(start.getTime() + 30 * 60 * 1000); + return { + id, + title: 'Test event', + start: start.toISOString(), + end: end.toISOString(), + allDay: false, + calendarId: 'cal-1', + calendarName: 'Test Calendar', + color: '#000000', + }; +} + +describe('useNotifications', () => { + let notificationSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + notificationSpy = vi.fn(); + // @ts-expect-error — minimal Notification stub for this test + globalThis.Notification = Object.assign(notificationSpy, { permission: 'granted' }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('respects a configured notifyBeforeMinutes instead of the old hardcoded 15', () => { + // Event is 10 minutes out. With the previous hardcoded 15-minute + // window this would already have notified; with the user's actual + // 5-minute setting it must not have, yet. + const event = eventIn(10); + renderHook(() => useNotifications([event], () => null, true, 5)); + + expect(notificationSpy).not.toHaveBeenCalled(); + }); + + it('fires once the event falls inside the configured window', () => { + const event = eventIn(10); + renderHook(() => useNotifications([event], () => null, true, 15)); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).toHaveBeenCalledWith( + 'Test event', + expect.objectContaining({ tag: 'beacon-event-e1' }), + ); + }); + + it('defaults to 15 minutes when no setting is passed, matching prior behavior', () => { + const event = eventIn(10); + renderHook(() => useNotifications([event], () => null, true)); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/beacon/src/hooks/useNotifications.ts b/beacon/src/hooks/useNotifications.ts index 65055c83..61e22cee 100644 --- a/beacon/src/hooks/useNotifications.ts +++ b/beacon/src/hooks/useNotifications.ts @@ -4,18 +4,22 @@ import { HomeAssistantClient } from '../api/homeassistant'; import beaconIcon from '../assets/beacon-app-icon.svg'; const CHECK_INTERVAL = 60 * 1000; // 1 minute -const NOTIFY_BEFORE_MS = 15 * 60 * 1000; // 15 minutes +const DEFAULT_NOTIFY_BEFORE_MINUTES = 15; /** * Checks upcoming events every minute and fires browser notifications - * 15 minutes before each event. Also sends HA mobile_app notifications - * if the client is connected. + * notifyBeforeMinutes before each event (Settings > Calendar > + * notificationTimingLabel — previously this was hardcoded to 15 minutes + * regardless of that setting). Also sends HA mobile_app notifications if + * the client is connected. */ export function useNotifications( events: CalendarEvent[], getClient: () => HomeAssistantClient | null, enabled = true, + notifyBeforeMinutes: number = DEFAULT_NOTIFY_BEFORE_MINUTES, ) { + const notifyBeforeMs = notifyBeforeMinutes * 60 * 1000; // Track which event IDs we've already notified about to avoid duplicates const notifiedRef = useRef>(new Set()); @@ -37,7 +41,7 @@ export function useNotifications( const diff = eventStart - now; // Event is within 15 minutes but hasn't started yet, and we haven't notified - if (diff > 0 && diff <= NOTIFY_BEFORE_MS && !notifiedRef.current.has(event.id)) { + if (diff > 0 && diff <= notifyBeforeMs && !notifiedRef.current.has(event.id)) { notifiedRef.current.add(event.id); const minutesUntil = Math.round(diff / 60_000); @@ -69,7 +73,7 @@ export function useNotifications( notifiedRef.current.delete(id); } } - }, [events, getClient]); + }, [events, getClient, notifyBeforeMs]); useEffect(() => { if (!enabled) return; diff --git a/beacon/src/i18n/translations/de.ts b/beacon/src/i18n/translations/de.ts index 87103b97..0f812d2d 100644 --- a/beacon/src/i18n/translations/de.ts +++ b/beacon/src/i18n/translations/de.ts @@ -363,4 +363,72 @@ export const de: Record = { 'settings.about.importSettings': 'Einstellungen importieren', 'settings.about.clearLocalStorage': 'Lokalen Speicher löschen', 'settings.about.resetToDefaults': 'Auf Standardwerte zurücksetzen', + + 'cards.common.entityLabel': 'Entität', + 'cards.common.entitiesLabel': 'Entitäten', + 'cards.common.titleLabel': 'Titel', + 'cards.common.subtitleLabel': 'Untertitel', + 'cards.clockWeather.displayName': 'Uhr & Wetter', + 'cards.familyCalendar.displayName': 'Familienkalender', + 'cards.familyCalendar.showOtherLabel': 'Andere anzeigen', + 'cards.familyCalendar.showOtherDescription': 'Termine anzeigen, die keinem Familienmitglied zugeordnet sind', + 'cards.agendaToday.displayName': 'Heute', + 'cards.agendaWeek.displayName': 'Diese Woche', + 'cards.menu.displayName': 'Speiseplan', + 'cards.tasks.displayName': 'Aufgaben', + 'cards.shopping.displayName': 'Einkaufen', + 'cards.shopping.entityLabel': 'Einkaufslisten-Entität', + 'cards.haEntity.displayName': 'HA-Entität', + 'cards.haEntitiesList.displayName': 'HA-Entitätenliste', + 'cards.haToggle.displayName': 'HA-Schalter', + 'weatherView.loading': 'Wetterdaten werden geladen...', + 'weatherView.notConnected': 'Nicht mit Home Assistant verbunden', + 'weatherView.noEntityFound': 'Keine Wetter-Entität gefunden', + 'weatherView.fetchFailed': 'Wetter konnte nicht abgerufen werden', + 'weatherView.retry': 'Erneut versuchen', + 'weatherView.feelsLike': 'Gefühlt wie', + 'weatherView.humidity': 'Luftfeuchtigkeit', + 'weatherView.wind': 'Wind', + 'weatherView.pressure': 'Luftdruck', + 'weatherView.refreshWeather': 'Wetter aktualisieren', + 'weatherView.sevenDayForecast': '7-Tage-Vorhersage', + 'weatherView.hourlyForDay': 'Stündlich — {day}', + 'weatherView.collapse': 'Einklappen', + 'weatherView.closeHourlyForecastAria': 'Stündliche Vorhersage schließen', + 'weatherView.loadingHourly': 'Stündliche Daten werden geladen...', + 'weatherView.noHourlyData': 'Keine stündlichen Daten für diesen Tag verfügbar.', + 'timer.modeTimers': 'Timer', + 'timer.modeStopwatch': 'Stoppuhr', + 'timer.soundChime': 'Glockenspiel', + 'timer.soundBeep': 'Piepton', + 'timer.soundAlarm': 'Alarm', + 'timer.soundKitchen': 'Küche', + 'timer.soundGentle': 'Sanft', + 'timer.previewSound': '{name} anhören', + 'timer.namePlaceholder': 'Timer-Name (optional)', + 'timer.startTimer': 'Timer starten', + 'timer.start': 'Start', + 'timer.dismissAlarm': 'Alarm beenden', + 'timer.pause': 'Pause', + 'timer.resume': 'Fortsetzen', + 'timer.remove': 'Entfernen', + 'timer.lap': 'Runde', + 'timer.reset': 'Zurücksetzen', + 'timer.lapN': 'Runde {n}', + 'countdown.title': 'Countdown', + 'countdown.add': 'Countdown hinzufügen', + 'countdown.eventNamePlaceholder': 'Ereignisname', + 'countdown.addBtn': 'Hinzufügen', + 'countdown.day': 'Tag', + 'countdown.days': 'Tage', + 'countdown.remove': 'Entfernen', + 'countdown.removeNamed': '{name} entfernen', + 'photoFrame.noPhotos': 'Keine Fotos verfügbar', + 'photoFrame.addPhotosHint': 'Fotos zu deinem Home-Assistant-Medienverzeichnis hinzufügen', + 'photoFrame.backToDashboard': 'Zurück zum Dashboard', + 'photoFrame.photoAlt': 'Foto', + 'photoFrame.previousPhoto': 'Vorheriges Foto', + 'photoFrame.pauseSlideshow': 'Diashow pausieren', + 'photoFrame.resumeSlideshow': 'Diashow fortsetzen', + 'photoFrame.nextPhoto': 'Nächstes Foto', }; diff --git a/beacon/src/i18n/translations/en.ts b/beacon/src/i18n/translations/en.ts index d80fffb7..19e92bc3 100644 --- a/beacon/src/i18n/translations/en.ts +++ b/beacon/src/i18n/translations/en.ts @@ -368,6 +368,83 @@ export const en = { 'settings.about.importSettings': 'Import Settings', 'settings.about.clearLocalStorage': 'Clear localStorage', 'settings.about.resetToDefaults': 'Reset to Defaults', + + // ---- dashboard cards: registry (names, config field labels) ---- + 'cards.common.entityLabel': 'Entity', + 'cards.common.entitiesLabel': 'Entities', + 'cards.common.titleLabel': 'Title', + 'cards.common.subtitleLabel': 'Subtitle', + 'cards.clockWeather.displayName': 'Clock & Weather', + 'cards.familyCalendar.displayName': 'Family Calendar', + 'cards.familyCalendar.showOtherLabel': 'Show Other', + 'cards.familyCalendar.showOtherDescription': 'Display events not assigned to a family member', + 'cards.agendaToday.displayName': 'Today', + 'cards.agendaWeek.displayName': 'This Week', + 'cards.menu.displayName': 'Menu', + 'cards.tasks.displayName': 'Tasks', + 'cards.shopping.displayName': 'Shopping', + 'cards.shopping.entityLabel': 'Shopping List Entity', + 'cards.haEntity.displayName': 'HA Entity', + 'cards.haEntitiesList.displayName': 'HA Entities List', + 'cards.haToggle.displayName': 'HA Toggle', + + // ---- weather view (full-screen Weather tab) ---- + 'weatherView.loading': 'Loading weather data...', + 'weatherView.notConnected': 'Not connected to Home Assistant', + 'weatherView.noEntityFound': 'No weather entity found', + 'weatherView.fetchFailed': 'Failed to fetch weather', + 'weatherView.retry': 'Retry', + 'weatherView.feelsLike': 'Feels like', + 'weatherView.humidity': 'Humidity', + 'weatherView.wind': 'Wind', + 'weatherView.pressure': 'Pressure', + 'weatherView.refreshWeather': 'Refresh weather', + 'weatherView.sevenDayForecast': '7-Day Forecast', + 'weatherView.hourlyForDay': 'Hourly — {day}', + 'weatherView.collapse': 'Collapse', + 'weatherView.closeHourlyForecastAria': 'Close hourly forecast', + 'weatherView.loadingHourly': 'Loading hourly data...', + 'weatherView.noHourlyData': 'No hourly data available for this day.', + + // ---- timer widget (Timer tab) ---- + 'timer.modeTimers': 'Timers', + 'timer.modeStopwatch': 'Stopwatch', + 'timer.soundChime': 'Chime', + 'timer.soundBeep': 'Beep', + 'timer.soundAlarm': 'Alarm', + 'timer.soundKitchen': 'Kitchen', + 'timer.soundGentle': 'Gentle', + 'timer.previewSound': 'Preview {name}', + 'timer.namePlaceholder': 'Timer name (optional)', + 'timer.startTimer': 'Start timer', + 'timer.start': 'Start', + 'timer.dismissAlarm': 'Dismiss alarm', + 'timer.pause': 'Pause', + 'timer.resume': 'Resume', + 'timer.remove': 'Remove', + 'timer.lap': 'Lap', + 'timer.reset': 'Reset', + 'timer.lapN': 'Lap {n}', + + // ---- countdown widget (Dashboard sidebar) ---- + 'countdown.title': 'Countdown', + 'countdown.add': 'Add countdown', + 'countdown.eventNamePlaceholder': 'Event name', + 'countdown.addBtn': 'Add', + 'countdown.day': 'day', + 'countdown.days': 'days', + 'countdown.remove': 'Remove', + 'countdown.removeNamed': 'Remove {name}', + + // ---- photo frame (screensaver-style slideshow) ---- + 'photoFrame.noPhotos': 'No photos available', + 'photoFrame.addPhotosHint': 'Add photos to your Home Assistant media directory', + 'photoFrame.backToDashboard': 'Back to dashboard', + 'photoFrame.photoAlt': 'Photo', + 'photoFrame.previousPhoto': 'Previous photo', + 'photoFrame.pauseSlideshow': 'Pause slideshow', + 'photoFrame.resumeSlideshow': 'Resume slideshow', + 'photoFrame.nextPhoto': 'Next photo', } as const; export type TranslationKey = keyof typeof en; diff --git a/beacon/src/i18n/translations/es.ts b/beacon/src/i18n/translations/es.ts index d158bd88..8c61c256 100644 --- a/beacon/src/i18n/translations/es.ts +++ b/beacon/src/i18n/translations/es.ts @@ -363,4 +363,72 @@ export const es: Record = { 'settings.about.importSettings': 'Importar ajustes', 'settings.about.clearLocalStorage': 'Borrar almacenamiento local', 'settings.about.resetToDefaults': 'Restablecer valores predeterminados', + + 'cards.common.entityLabel': 'Entidad', + 'cards.common.entitiesLabel': 'Entidades', + 'cards.common.titleLabel': 'Título', + 'cards.common.subtitleLabel': 'Subtítulo', + 'cards.clockWeather.displayName': 'Reloj y Clima', + 'cards.familyCalendar.displayName': 'Calendario Familiar', + 'cards.familyCalendar.showOtherLabel': 'Mostrar otros', + 'cards.familyCalendar.showOtherDescription': 'Mostrar eventos no asignados a un miembro de la familia', + 'cards.agendaToday.displayName': 'Hoy', + 'cards.agendaWeek.displayName': 'Esta Semana', + 'cards.menu.displayName': 'Menú', + 'cards.tasks.displayName': 'Tareas', + 'cards.shopping.displayName': 'Compras', + 'cards.shopping.entityLabel': 'Entidad de lista de compras', + 'cards.haEntity.displayName': 'Entidad de HA', + 'cards.haEntitiesList.displayName': 'Lista de entidades de HA', + 'cards.haToggle.displayName': 'Interruptor de HA', + 'weatherView.loading': 'Cargando datos del clima...', + 'weatherView.notConnected': 'No conectado a Home Assistant', + 'weatherView.noEntityFound': 'No se encontró ninguna entidad de clima', + 'weatherView.fetchFailed': 'No se pudo obtener el clima', + 'weatherView.retry': 'Reintentar', + 'weatherView.feelsLike': 'Sensación térmica', + 'weatherView.humidity': 'Humedad', + 'weatherView.wind': 'Viento', + 'weatherView.pressure': 'Presión', + 'weatherView.refreshWeather': 'Actualizar clima', + 'weatherView.sevenDayForecast': 'Pronóstico de 7 días', + 'weatherView.hourlyForDay': 'Por hora — {day}', + 'weatherView.collapse': 'Contraer', + 'weatherView.closeHourlyForecastAria': 'Cerrar pronóstico por hora', + 'weatherView.loadingHourly': 'Cargando datos por hora...', + 'weatherView.noHourlyData': 'No hay datos por hora disponibles para este día.', + 'timer.modeTimers': 'Temporizadores', + 'timer.modeStopwatch': 'Cronómetro', + 'timer.soundChime': 'Campanilla', + 'timer.soundBeep': 'Pitido', + 'timer.soundAlarm': 'Alarma', + 'timer.soundKitchen': 'Cocina', + 'timer.soundGentle': 'Suave', + 'timer.previewSound': 'Escuchar {name}', + 'timer.namePlaceholder': 'Nombre del temporizador (opcional)', + 'timer.startTimer': 'Iniciar temporizador', + 'timer.start': 'Iniciar', + 'timer.dismissAlarm': 'Descartar alarma', + 'timer.pause': 'Pausar', + 'timer.resume': 'Reanudar', + 'timer.remove': 'Eliminar', + 'timer.lap': 'Vuelta', + 'timer.reset': 'Reiniciar', + 'timer.lapN': 'Vuelta {n}', + 'countdown.title': 'Cuenta atrás', + 'countdown.add': 'Agregar cuenta atrás', + 'countdown.eventNamePlaceholder': 'Nombre del evento', + 'countdown.addBtn': 'Agregar', + 'countdown.day': 'día', + 'countdown.days': 'días', + 'countdown.remove': 'Eliminar', + 'countdown.removeNamed': 'Eliminar {name}', + 'photoFrame.noPhotos': 'No hay fotos disponibles', + 'photoFrame.addPhotosHint': 'Agrega fotos a tu directorio de medios de Home Assistant', + 'photoFrame.backToDashboard': 'Volver al panel', + 'photoFrame.photoAlt': 'Foto', + 'photoFrame.previousPhoto': 'Foto anterior', + 'photoFrame.pauseSlideshow': 'Pausar presentación', + 'photoFrame.resumeSlideshow': 'Reanudar presentación', + 'photoFrame.nextPhoto': 'Siguiente foto', }; diff --git a/beacon/src/i18n/translations/fr.ts b/beacon/src/i18n/translations/fr.ts index 4a6bde24..0aba3a56 100644 --- a/beacon/src/i18n/translations/fr.ts +++ b/beacon/src/i18n/translations/fr.ts @@ -363,4 +363,72 @@ export const fr: Record = { 'settings.about.importSettings': 'Importer les paramètres', 'settings.about.clearLocalStorage': 'Effacer le stockage local', 'settings.about.resetToDefaults': 'Réinitialiser aux valeurs par défaut', + + 'cards.common.entityLabel': 'Entité', + 'cards.common.entitiesLabel': 'Entités', + 'cards.common.titleLabel': 'Titre', + 'cards.common.subtitleLabel': 'Sous-titre', + 'cards.clockWeather.displayName': 'Horloge et météo', + 'cards.familyCalendar.displayName': 'Calendrier familial', + 'cards.familyCalendar.showOtherLabel': 'Afficher les autres', + 'cards.familyCalendar.showOtherDescription': "Afficher les événements non attribués à un membre de la famille", + 'cards.agendaToday.displayName': "Aujourd'hui", + 'cards.agendaWeek.displayName': 'Cette semaine', + 'cards.menu.displayName': 'Menu', + 'cards.tasks.displayName': 'Tâches', + 'cards.shopping.displayName': 'Courses', + 'cards.shopping.entityLabel': 'Entité liste de courses', + 'cards.haEntity.displayName': 'Entité HA', + 'cards.haEntitiesList.displayName': "Liste d'entités HA", + 'cards.haToggle.displayName': 'Interrupteur HA', + 'weatherView.loading': 'Chargement des données météo...', + 'weatherView.notConnected': 'Non connecté à Home Assistant', + 'weatherView.noEntityFound': 'Aucune entité météo trouvée', + 'weatherView.fetchFailed': 'Échec de la récupération de la météo', + 'weatherView.retry': 'Réessayer', + 'weatherView.feelsLike': 'Ressenti', + 'weatherView.humidity': 'Humidité', + 'weatherView.wind': 'Vent', + 'weatherView.pressure': 'Pression', + 'weatherView.refreshWeather': 'Actualiser la météo', + 'weatherView.sevenDayForecast': 'Prévisions sur 7 jours', + 'weatherView.hourlyForDay': 'Horaire — {day}', + 'weatherView.collapse': 'Réduire', + 'weatherView.closeHourlyForecastAria': 'Fermer les prévisions horaires', + 'weatherView.loadingHourly': 'Chargement des données horaires...', + 'weatherView.noHourlyData': 'Aucune donnée horaire disponible pour ce jour.', + 'timer.modeTimers': 'Minuteurs', + 'timer.modeStopwatch': 'Chronomètre', + 'timer.soundChime': 'Carillon', + 'timer.soundBeep': 'Bip', + 'timer.soundAlarm': 'Alarme', + 'timer.soundKitchen': 'Cuisine', + 'timer.soundGentle': 'Doux', + 'timer.previewSound': 'Écouter {name}', + 'timer.namePlaceholder': 'Nom du minuteur (facultatif)', + 'timer.startTimer': 'Démarrer le minuteur', + 'timer.start': 'Démarrer', + 'timer.dismissAlarm': 'Arrêter l\'alarme', + 'timer.pause': 'Pause', + 'timer.resume': 'Reprendre', + 'timer.remove': 'Supprimer', + 'timer.lap': 'Tour', + 'timer.reset': 'Réinitialiser', + 'timer.lapN': 'Tour {n}', + 'countdown.title': 'Compte à rebours', + 'countdown.add': 'Ajouter un compte à rebours', + 'countdown.eventNamePlaceholder': 'Nom de l\'événement', + 'countdown.addBtn': 'Ajouter', + 'countdown.day': 'jour', + 'countdown.days': 'jours', + 'countdown.remove': 'Supprimer', + 'countdown.removeNamed': 'Supprimer {name}', + 'photoFrame.noPhotos': 'Aucune photo disponible', + 'photoFrame.addPhotosHint': 'Ajoutez des photos à votre répertoire multimédia Home Assistant', + 'photoFrame.backToDashboard': 'Retour au tableau de bord', + 'photoFrame.photoAlt': 'Photo', + 'photoFrame.previousPhoto': 'Photo précédente', + 'photoFrame.pauseSlideshow': 'Mettre le diaporama en pause', + 'photoFrame.resumeSlideshow': 'Reprendre le diaporama', + 'photoFrame.nextPhoto': 'Photo suivante', }; diff --git a/beacon/src/i18n/translations/it.ts b/beacon/src/i18n/translations/it.ts index 42d7ac33..1c274b31 100644 --- a/beacon/src/i18n/translations/it.ts +++ b/beacon/src/i18n/translations/it.ts @@ -363,4 +363,72 @@ export const it: Record = { 'settings.about.importSettings': 'Importa impostazioni', 'settings.about.clearLocalStorage': 'Cancella archiviazione locale', 'settings.about.resetToDefaults': 'Ripristina valori predefiniti', + + 'cards.common.entityLabel': 'Entità', + 'cards.common.entitiesLabel': 'Entità', + 'cards.common.titleLabel': 'Titolo', + 'cards.common.subtitleLabel': 'Sottotitolo', + 'cards.clockWeather.displayName': 'Orologio e Meteo', + 'cards.familyCalendar.displayName': 'Calendario di Famiglia', + 'cards.familyCalendar.showOtherLabel': 'Mostra altri', + 'cards.familyCalendar.showOtherDescription': 'Mostra eventi non assegnati a un membro della famiglia', + 'cards.agendaToday.displayName': 'Oggi', + 'cards.agendaWeek.displayName': 'Questa Settimana', + 'cards.menu.displayName': 'Menu', + 'cards.tasks.displayName': 'Attività', + 'cards.shopping.displayName': 'Spesa', + 'cards.shopping.entityLabel': 'Entità lista della spesa', + 'cards.haEntity.displayName': 'Entità HA', + 'cards.haEntitiesList.displayName': 'Elenco entità HA', + 'cards.haToggle.displayName': 'Interruttore HA', + 'weatherView.loading': 'Caricamento dati meteo...', + 'weatherView.notConnected': 'Non connesso a Home Assistant', + 'weatherView.noEntityFound': 'Nessuna entità meteo trovata', + 'weatherView.fetchFailed': 'Impossibile recuperare il meteo', + 'weatherView.retry': 'Riprova', + 'weatherView.feelsLike': 'Percepita', + 'weatherView.humidity': 'Umidità', + 'weatherView.wind': 'Vento', + 'weatherView.pressure': 'Pressione', + 'weatherView.refreshWeather': 'Aggiorna meteo', + 'weatherView.sevenDayForecast': 'Previsioni a 7 giorni', + 'weatherView.hourlyForDay': 'Orario — {day}', + 'weatherView.collapse': 'Comprimi', + 'weatherView.closeHourlyForecastAria': 'Chiudi previsioni orarie', + 'weatherView.loadingHourly': 'Caricamento dati orari...', + 'weatherView.noHourlyData': 'Nessun dato orario disponibile per questo giorno.', + 'timer.modeTimers': 'Timer', + 'timer.modeStopwatch': 'Cronometro', + 'timer.soundChime': 'Carillon', + 'timer.soundBeep': 'Bip', + 'timer.soundAlarm': 'Allarme', + 'timer.soundKitchen': 'Cucina', + 'timer.soundGentle': 'Delicato', + 'timer.previewSound': 'Ascolta {name}', + 'timer.namePlaceholder': 'Nome timer (opzionale)', + 'timer.startTimer': 'Avvia timer', + 'timer.start': 'Avvia', + 'timer.dismissAlarm': 'Ignora allarme', + 'timer.pause': 'Pausa', + 'timer.resume': 'Riprendi', + 'timer.remove': 'Rimuovi', + 'timer.lap': 'Giro', + 'timer.reset': 'Reimposta', + 'timer.lapN': 'Giro {n}', + 'countdown.title': 'Conto alla rovescia', + 'countdown.add': 'Aggiungi conto alla rovescia', + 'countdown.eventNamePlaceholder': 'Nome evento', + 'countdown.addBtn': 'Aggiungi', + 'countdown.day': 'giorno', + 'countdown.days': 'giorni', + 'countdown.remove': 'Rimuovi', + 'countdown.removeNamed': 'Rimuovi {name}', + 'photoFrame.noPhotos': 'Nessuna foto disponibile', + 'photoFrame.addPhotosHint': 'Aggiungi foto alla tua directory multimediale di Home Assistant', + 'photoFrame.backToDashboard': 'Torna alla dashboard', + 'photoFrame.photoAlt': 'Foto', + 'photoFrame.previousPhoto': 'Foto precedente', + 'photoFrame.pauseSlideshow': 'Metti in pausa la presentazione', + 'photoFrame.resumeSlideshow': 'Riprendi la presentazione', + 'photoFrame.nextPhoto': 'Foto successiva', }; diff --git a/beacon/src/i18n/translations/ja.ts b/beacon/src/i18n/translations/ja.ts index daedfcc4..9ed214b8 100644 --- a/beacon/src/i18n/translations/ja.ts +++ b/beacon/src/i18n/translations/ja.ts @@ -363,4 +363,72 @@ export const ja: Record = { 'settings.about.importSettings': '設定をインポート', 'settings.about.clearLocalStorage': 'ローカルストレージを消去', 'settings.about.resetToDefaults': 'デフォルトに戻す', + + 'cards.common.entityLabel': 'エンティティ', + 'cards.common.entitiesLabel': 'エンティティ', + 'cards.common.titleLabel': 'タイトル', + 'cards.common.subtitleLabel': 'サブタイトル', + 'cards.clockWeather.displayName': '時計と天気', + 'cards.familyCalendar.displayName': '家族カレンダー', + 'cards.familyCalendar.showOtherLabel': 'その他を表示', + 'cards.familyCalendar.showOtherDescription': '家族のメンバーに割り当てられていない予定を表示します', + 'cards.agendaToday.displayName': '今日', + 'cards.agendaWeek.displayName': '今週', + 'cards.menu.displayName': 'メニュー', + 'cards.tasks.displayName': 'タスク', + 'cards.shopping.displayName': '買い物', + 'cards.shopping.entityLabel': '買い物リストのエンティティ', + 'cards.haEntity.displayName': 'HAエンティティ', + 'cards.haEntitiesList.displayName': 'HAエンティティ一覧', + 'cards.haToggle.displayName': 'HAスイッチ', + 'weatherView.loading': '天気データを読み込み中...', + 'weatherView.notConnected': 'Home Assistant に接続されていません', + 'weatherView.noEntityFound': '天気エンティティが見つかりません', + 'weatherView.fetchFailed': '天気の取得に失敗しました', + 'weatherView.retry': '再試行', + 'weatherView.feelsLike': '体感温度', + 'weatherView.humidity': '湿度', + 'weatherView.wind': '風', + 'weatherView.pressure': '気圧', + 'weatherView.refreshWeather': '天気を更新', + 'weatherView.sevenDayForecast': '7日間の予報', + 'weatherView.hourlyForDay': '時間別 — {day}', + 'weatherView.collapse': '閉じる', + 'weatherView.closeHourlyForecastAria': '時間別予報を閉じる', + 'weatherView.loadingHourly': '時間別データを読み込み中...', + 'weatherView.noHourlyData': 'この日の時間別データはありません。', + 'timer.modeTimers': 'タイマー', + 'timer.modeStopwatch': 'ストップウォッチ', + 'timer.soundChime': 'チャイム', + 'timer.soundBeep': 'ビープ', + 'timer.soundAlarm': 'アラーム', + 'timer.soundKitchen': 'キッチン', + 'timer.soundGentle': 'やさしい音', + 'timer.previewSound': '{name}を試聴', + 'timer.namePlaceholder': 'タイマー名(任意)', + 'timer.startTimer': 'タイマー開始', + 'timer.start': '開始', + 'timer.dismissAlarm': 'アラームを止める', + 'timer.pause': '一時停止', + 'timer.resume': '再開', + 'timer.remove': '削除', + 'timer.lap': 'ラップ', + 'timer.reset': 'リセット', + 'timer.lapN': 'ラップ {n}', + 'countdown.title': 'カウントダウン', + 'countdown.add': 'カウントダウンを追加', + 'countdown.eventNamePlaceholder': 'イベント名', + 'countdown.addBtn': '追加', + 'countdown.day': '日', + 'countdown.days': '日', + 'countdown.remove': '削除', + 'countdown.removeNamed': '{name}を削除', + 'photoFrame.noPhotos': '利用可能な写真がありません', + 'photoFrame.addPhotosHint': 'Home Assistant のメディアディレクトリに写真を追加してください', + 'photoFrame.backToDashboard': 'ダッシュボードに戻る', + 'photoFrame.photoAlt': '写真', + 'photoFrame.previousPhoto': '前の写真', + 'photoFrame.pauseSlideshow': 'スライドショーを一時停止', + 'photoFrame.resumeSlideshow': 'スライドショーを再開', + 'photoFrame.nextPhoto': '次の写真', }; diff --git a/beacon/src/i18n/translations/ko.ts b/beacon/src/i18n/translations/ko.ts index 0638c618..c6d82294 100644 --- a/beacon/src/i18n/translations/ko.ts +++ b/beacon/src/i18n/translations/ko.ts @@ -363,4 +363,72 @@ export const ko: Record = { 'settings.about.importSettings': '설정 가져오기', 'settings.about.clearLocalStorage': '로컬 저장소 지우기', 'settings.about.resetToDefaults': '기본값으로 재설정', + + 'cards.common.entityLabel': '엔티티', + 'cards.common.entitiesLabel': '엔티티', + 'cards.common.titleLabel': '제목', + 'cards.common.subtitleLabel': '부제목', + 'cards.clockWeather.displayName': '시계 및 날씨', + 'cards.familyCalendar.displayName': '가족 캘린더', + 'cards.familyCalendar.showOtherLabel': '기타 표시', + 'cards.familyCalendar.showOtherDescription': '가족 구성원에게 할당되지 않은 일정 표시', + 'cards.agendaToday.displayName': '오늘', + 'cards.agendaWeek.displayName': '이번 주', + 'cards.menu.displayName': '메뉴', + 'cards.tasks.displayName': '할 일', + 'cards.shopping.displayName': '쇼핑', + 'cards.shopping.entityLabel': '쇼핑 목록 엔티티', + 'cards.haEntity.displayName': 'HA 엔티티', + 'cards.haEntitiesList.displayName': 'HA 엔티티 목록', + 'cards.haToggle.displayName': 'HA 스위치', + 'weatherView.loading': '날씨 데이터를 불러오는 중...', + 'weatherView.notConnected': 'Home Assistant에 연결되지 않음', + 'weatherView.noEntityFound': '날씨 엔티티를 찾을 수 없습니다', + 'weatherView.fetchFailed': '날씨를 가져오지 못했습니다', + 'weatherView.retry': '다시 시도', + 'weatherView.feelsLike': '체감 온도', + 'weatherView.humidity': '습도', + 'weatherView.wind': '바람', + 'weatherView.pressure': '기압', + 'weatherView.refreshWeather': '날씨 새로고침', + 'weatherView.sevenDayForecast': '7일 예보', + 'weatherView.hourlyForDay': '시간별 — {day}', + 'weatherView.collapse': '접기', + 'weatherView.closeHourlyForecastAria': '시간별 예보 닫기', + 'weatherView.loadingHourly': '시간별 데이터를 불러오는 중...', + 'weatherView.noHourlyData': '이 날짜의 시간별 데이터가 없습니다.', + 'timer.modeTimers': '타이머', + 'timer.modeStopwatch': '스톱워치', + 'timer.soundChime': '차임', + 'timer.soundBeep': '비프음', + 'timer.soundAlarm': '알람', + 'timer.soundKitchen': '키친', + 'timer.soundGentle': '부드러운 소리', + 'timer.previewSound': '{name} 미리 듣기', + 'timer.namePlaceholder': '타이머 이름 (선택 사항)', + 'timer.startTimer': '타이머 시작', + 'timer.start': '시작', + 'timer.dismissAlarm': '알람 끄기', + 'timer.pause': '일시정지', + 'timer.resume': '재개', + 'timer.remove': '제거', + 'timer.lap': '랩', + 'timer.reset': '재설정', + 'timer.lapN': '랩 {n}', + 'countdown.title': '카운트다운', + 'countdown.add': '카운트다운 추가', + 'countdown.eventNamePlaceholder': '이벤트 이름', + 'countdown.addBtn': '추가', + 'countdown.day': '일', + 'countdown.days': '일', + 'countdown.remove': '제거', + 'countdown.removeNamed': '{name} 제거', + 'photoFrame.noPhotos': '사용 가능한 사진이 없습니다', + 'photoFrame.addPhotosHint': 'Home Assistant 미디어 디렉터리에 사진을 추가하세요', + 'photoFrame.backToDashboard': '대시보드로 돌아가기', + 'photoFrame.photoAlt': '사진', + 'photoFrame.previousPhoto': '이전 사진', + 'photoFrame.pauseSlideshow': '슬라이드쇼 일시정지', + 'photoFrame.resumeSlideshow': '슬라이드쇼 재개', + 'photoFrame.nextPhoto': '다음 사진', }; diff --git a/beacon/src/i18n/translations/nl.ts b/beacon/src/i18n/translations/nl.ts index 6c239877..7ceb5248 100644 --- a/beacon/src/i18n/translations/nl.ts +++ b/beacon/src/i18n/translations/nl.ts @@ -363,4 +363,72 @@ export const nl: Record = { 'settings.about.importSettings': 'Instellingen importeren', 'settings.about.clearLocalStorage': 'Lokale opslag wissen', 'settings.about.resetToDefaults': 'Terugzetten naar standaardwaarden', + + 'cards.common.entityLabel': 'Entiteit', + 'cards.common.entitiesLabel': 'Entiteiten', + 'cards.common.titleLabel': 'Titel', + 'cards.common.subtitleLabel': 'Ondertitel', + 'cards.clockWeather.displayName': 'Klok & Weer', + 'cards.familyCalendar.displayName': 'Gezinskalender', + 'cards.familyCalendar.showOtherLabel': 'Overige tonen', + 'cards.familyCalendar.showOtherDescription': 'Toon afspraken die niet aan een gezinslid zijn toegewezen', + 'cards.agendaToday.displayName': 'Vandaag', + 'cards.agendaWeek.displayName': 'Deze Week', + 'cards.menu.displayName': 'Menu', + 'cards.tasks.displayName': 'Taken', + 'cards.shopping.displayName': 'Boodschappen', + 'cards.shopping.entityLabel': 'Boodschappenlijst-entiteit', + 'cards.haEntity.displayName': 'HA-entiteit', + 'cards.haEntitiesList.displayName': 'HA-entiteitenlijst', + 'cards.haToggle.displayName': 'HA-schakelaar', + 'weatherView.loading': 'Weergegevens laden...', + 'weatherView.notConnected': 'Niet verbonden met Home Assistant', + 'weatherView.noEntityFound': 'Geen weer-entiteit gevonden', + 'weatherView.fetchFailed': 'Ophalen van het weer is mislukt', + 'weatherView.retry': 'Opnieuw proberen', + 'weatherView.feelsLike': 'Gevoelstemperatuur', + 'weatherView.humidity': 'Luchtvochtigheid', + 'weatherView.wind': 'Wind', + 'weatherView.pressure': 'Luchtdruk', + 'weatherView.refreshWeather': 'Weer vernieuwen', + 'weatherView.sevenDayForecast': '7-daagse voorspelling', + 'weatherView.hourlyForDay': 'Per uur — {day}', + 'weatherView.collapse': 'Inklappen', + 'weatherView.closeHourlyForecastAria': 'Uurverwachting sluiten', + 'weatherView.loadingHourly': 'Uurgegevens laden...', + 'weatherView.noHourlyData': 'Geen uurgegevens beschikbaar voor deze dag.', + 'timer.modeTimers': 'Timers', + 'timer.modeStopwatch': 'Stopwatch', + 'timer.soundChime': 'Klokkenspel', + 'timer.soundBeep': 'Piep', + 'timer.soundAlarm': 'Alarm', + 'timer.soundKitchen': 'Keuken', + 'timer.soundGentle': 'Zacht', + 'timer.previewSound': '{name} beluisteren', + 'timer.namePlaceholder': 'Timernaam (optioneel)', + 'timer.startTimer': 'Timer starten', + 'timer.start': 'Start', + 'timer.dismissAlarm': 'Alarm sluiten', + 'timer.pause': 'Pauzeren', + 'timer.resume': 'Hervatten', + 'timer.remove': 'Verwijderen', + 'timer.lap': 'Ronde', + 'timer.reset': 'Resetten', + 'timer.lapN': 'Ronde {n}', + 'countdown.title': 'Aftellen', + 'countdown.add': 'Aftellen toevoegen', + 'countdown.eventNamePlaceholder': 'Naam van gebeurtenis', + 'countdown.addBtn': 'Toevoegen', + 'countdown.day': 'dag', + 'countdown.days': 'dagen', + 'countdown.remove': 'Verwijderen', + 'countdown.removeNamed': '{name} verwijderen', + 'photoFrame.noPhotos': 'Geen foto\'s beschikbaar', + 'photoFrame.addPhotosHint': 'Voeg foto\'s toe aan je Home Assistant-mediamap', + 'photoFrame.backToDashboard': 'Terug naar dashboard', + 'photoFrame.photoAlt': 'Foto', + 'photoFrame.previousPhoto': 'Vorige foto', + 'photoFrame.pauseSlideshow': 'Diavoorstelling pauzeren', + 'photoFrame.resumeSlideshow': 'Diavoorstelling hervatten', + 'photoFrame.nextPhoto': 'Volgende foto', }; diff --git a/beacon/src/i18n/translations/pt.ts b/beacon/src/i18n/translations/pt.ts index 5cbeb243..a1701186 100644 --- a/beacon/src/i18n/translations/pt.ts +++ b/beacon/src/i18n/translations/pt.ts @@ -363,4 +363,72 @@ export const pt: Record = { 'settings.about.importSettings': 'Importar definições', 'settings.about.clearLocalStorage': 'Limpar armazenamento local', 'settings.about.resetToDefaults': 'Repor predefinições', + + 'cards.common.entityLabel': 'Entidade', + 'cards.common.entitiesLabel': 'Entidades', + 'cards.common.titleLabel': 'Título', + 'cards.common.subtitleLabel': 'Subtítulo', + 'cards.clockWeather.displayName': 'Relógio e Clima', + 'cards.familyCalendar.displayName': 'Calendário da Família', + 'cards.familyCalendar.showOtherLabel': 'Mostrar outros', + 'cards.familyCalendar.showOtherDescription': 'Mostrar eventos não atribuídos a um membro da família', + 'cards.agendaToday.displayName': 'Hoje', + 'cards.agendaWeek.displayName': 'Esta Semana', + 'cards.menu.displayName': 'Cardápio', + 'cards.tasks.displayName': 'Tarefas', + 'cards.shopping.displayName': 'Compras', + 'cards.shopping.entityLabel': 'Entidade da lista de compras', + 'cards.haEntity.displayName': 'Entidade HA', + 'cards.haEntitiesList.displayName': 'Lista de entidades HA', + 'cards.haToggle.displayName': 'Interruptor HA', + 'weatherView.loading': 'Carregando dados do clima...', + 'weatherView.notConnected': 'Não conectado ao Home Assistant', + 'weatherView.noEntityFound': 'Nenhuma entidade de clima encontrada', + 'weatherView.fetchFailed': 'Falha ao obter o clima', + 'weatherView.retry': 'Tentar novamente', + 'weatherView.feelsLike': 'Sensação térmica', + 'weatherView.humidity': 'Umidade', + 'weatherView.wind': 'Vento', + 'weatherView.pressure': 'Pressão', + 'weatherView.refreshWeather': 'Atualizar clima', + 'weatherView.sevenDayForecast': 'Previsão de 7 dias', + 'weatherView.hourlyForDay': 'Por hora — {day}', + 'weatherView.collapse': 'Recolher', + 'weatherView.closeHourlyForecastAria': 'Fechar previsão por hora', + 'weatherView.loadingHourly': 'Carregando dados por hora...', + 'weatherView.noHourlyData': 'Nenhum dado por hora disponível para este dia.', + 'timer.modeTimers': 'Temporizadores', + 'timer.modeStopwatch': 'Cronômetro', + 'timer.soundChime': 'Sino', + 'timer.soundBeep': 'Bipe', + 'timer.soundAlarm': 'Alarme', + 'timer.soundKitchen': 'Cozinha', + 'timer.soundGentle': 'Suave', + 'timer.previewSound': 'Ouvir {name}', + 'timer.namePlaceholder': 'Nome do temporizador (opcional)', + 'timer.startTimer': 'Iniciar temporizador', + 'timer.start': 'Iniciar', + 'timer.dismissAlarm': 'Descartar alarme', + 'timer.pause': 'Pausar', + 'timer.resume': 'Retomar', + 'timer.remove': 'Remover', + 'timer.lap': 'Volta', + 'timer.reset': 'Reiniciar', + 'timer.lapN': 'Volta {n}', + 'countdown.title': 'Contagem regressiva', + 'countdown.add': 'Adicionar contagem regressiva', + 'countdown.eventNamePlaceholder': 'Nome do evento', + 'countdown.addBtn': 'Adicionar', + 'countdown.day': 'dia', + 'countdown.days': 'dias', + 'countdown.remove': 'Remover', + 'countdown.removeNamed': 'Remover {name}', + 'photoFrame.noPhotos': 'Nenhuma foto disponível', + 'photoFrame.addPhotosHint': 'Adicione fotos ao diretório de mídia do Home Assistant', + 'photoFrame.backToDashboard': 'Voltar ao painel', + 'photoFrame.photoAlt': 'Foto', + 'photoFrame.previousPhoto': 'Foto anterior', + 'photoFrame.pauseSlideshow': 'Pausar apresentação', + 'photoFrame.resumeSlideshow': 'Retomar apresentação', + 'photoFrame.nextPhoto': 'Próxima foto', }; diff --git a/beacon/src/styles/beacon.css b/beacon/src/styles/beacon.css index 8274b9a3..cd2190f0 100644 --- a/beacon/src/styles/beacon.css +++ b/beacon/src/styles/beacon.css @@ -181,6 +181,12 @@ a:focus:not(:focus-visible) { display: flex; } +/* Kiosk Mode (Settings > Display): no Sidebar is rendered at all, so no + column needs to be reserved for it — single full-width column. */ +.beacon--kiosk { + grid-template-columns: 1fr; +} + .beacon-main { display: flex; flex-direction: column; diff --git a/beacon/src/types/dashboard-cards.ts b/beacon/src/types/dashboard-cards.ts index b8c86462..60999af0 100644 --- a/beacon/src/types/dashboard-cards.ts +++ b/beacon/src/types/dashboard-cards.ts @@ -3,6 +3,7 @@ import { CalendarEvent, WeatherData } from '../types'; import { Chore, FamilyMember } from './family'; import { DayMenu } from './meals'; import type { TaskmateUser } from './taskmate'; +import type { TranslationKey } from '../i18n/translations/en'; export interface TodoItem { uid: string; @@ -94,30 +95,30 @@ export type CardConfigField = | { type: 'text'; key: string; - label: string; + labelKey: TranslationKey; } | { type: 'entity'; key: string; - label: string; + labelKey: TranslationKey; } | { type: 'entity-list'; key: string; - label: string; + labelKey: TranslationKey; /** Previous single-entity key to migrate into this list when configured. */ legacyKey?: string; } | { type: 'toggle'; key: string; - label: string; - description?: string; + labelKey: TranslationKey; + descriptionKey?: TranslationKey; }; export interface CardDefinition { type: string; - displayName: string; + displayNameKey: TranslationKey; icon: string; component: DashboardCardComponent; defaultConfig: Record; diff --git a/scripts/check-beacon-sync.sh b/scripts/check-beacon-sync.sh new file mode 100755 index 00000000..c9d01799 --- /dev/null +++ b/scripts/check-beacon-sync.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Check that beacon/src/ is in sync with root src/ +# This ensures the HA add-on build includes all changes + +set -e + +echo "Checking beacon/src/ sync with root src/..." + +# Compare directories +if ! diff -r src/ beacon/src/ --brief; then + echo "" + echo "❌ ERROR: beacon/src/ is out of sync with root src/" + echo "" + echo "After ANY source change, you must sync with:" + echo " rsync -av --delete src/ beacon/src/" + echo " cp Dockerfile run.sh CHANGELOG.md beacon/" + echo "" + echo "See CLAUDE.md for details on the dual-directory structure." + exit 1 +fi + +# Check that required files are synced +for file in Dockerfile run.sh CHANGELOG.md; do + if ! diff "$file" "beacon/$file" > /dev/null 2>&1; then + echo "❌ ERROR: beacon/$file is out of sync with root" + echo "Run: cp $file beacon/" + exit 1 + fi +done + +echo "✅ beacon/ directory is in sync with root"