Skip to content

Commit c7dcf8f

Browse files
committed
refactor(cleanup): reduce dashboard and formatting duplication
1 parent bdaf198 commit c7dcf8f

30 files changed

Lines changed: 499 additions & 292 deletions

scripts/clean-root-artifacts.mjs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,63 @@
1-
import { rm } from 'node:fs/promises';
1+
import { readdir, rm } from 'node:fs/promises';
22
import path from 'node:path';
33

44
const cwd = process.cwd();
55
const removeAll = process.argv.includes('--all');
66

7-
const rootArtifacts = ['.DS_Store', '.vite', 'build-storybook.log', 'dist', 'storybook-static'];
7+
const ignoredDirectories = new Set(['.git', 'node_modules']);
8+
const rootArtifacts = [
9+
'.DS_Store',
10+
'.vite',
11+
'build-storybook.log',
12+
'debug-storybook.log',
13+
'dist',
14+
'storybook-static',
15+
];
816
const cacheArtifacts = ['.cache/vite', '.cache/storybook-static'];
917

1018
const targets = removeAll ? [...rootArtifacts, ...cacheArtifacts] : rootArtifacts;
1119

12-
for (const target of targets) {
20+
async function removeTarget(target) {
1321
const absolutePath = path.join(cwd, target);
1422
try {
15-
await rm(absolutePath, { recursive: true, force: true });
23+
await rm(absolutePath, { recursive: true });
1624
console.log(`removed ${target}`);
17-
} catch {
25+
} catch (error) {
26+
if (error?.code === 'ENOENT') {
27+
return;
28+
}
29+
1830
// Keep cleanup best-effort and idempotent.
1931
}
2032
}
33+
34+
async function removeOsArtifacts(directory) {
35+
let entries;
36+
try {
37+
entries = await readdir(directory, { withFileTypes: true });
38+
} catch {
39+
return;
40+
}
41+
42+
for (const entry of entries) {
43+
const absolutePath = path.join(directory, entry.name);
44+
const relativePath = path.relative(cwd, absolutePath);
45+
46+
if (entry.isDirectory()) {
47+
if (!ignoredDirectories.has(entry.name)) {
48+
await removeOsArtifacts(absolutePath);
49+
}
50+
continue;
51+
}
52+
53+
if (entry.isFile() && entry.name === '.DS_Store') {
54+
await removeTarget(relativePath);
55+
}
56+
}
57+
}
58+
59+
for (const target of targets) {
60+
await removeTarget(target);
61+
}
62+
63+
await removeOsArtifacts(cwd);

src/app/features/dashboard/components/custom-card-action.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22
import type { CardSize } from '@/app/components/shared/card-size-selector';
33
import { getStoryDocsDescription } from '@/app/storybook/story-docs';
4-
import { buildCustomCard, CustomWidgetStoryFrame } from '../stories/custom-card-story-helpers';
4+
import { buildCustomCard, CustomWidgetStoryFrame } from '@/app/storybook/story-frames';
55

66
type ActionStoryArgs = {
77
size: Extract<CardSize, 'tiny' | 'extra-small' | 'small'>;

src/app/features/dashboard/components/custom-card-battery-overview.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
44
import type { CardSize } from '@/app/components/shared/card-size-selector';
55
import { homeAssistantStore } from '@/app/stores/home-assistant-store';
66
import { getStoryDocsDescription } from '@/app/storybook/story-docs';
7-
import { buildCustomCard, CustomWidgetStoryFrame } from '../stories/custom-card-story-helpers';
7+
import { buildCustomCard, CustomWidgetStoryFrame } from '@/app/storybook/story-frames';
88

99
const sampleBatteryEntities: HassEntities = {
1010
'sensor.front_door_sensor_battery': {

src/app/features/dashboard/components/custom-card-map.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
44
import type { CardSize } from '@/app/components/shared/card-size-selector';
55
import { homeAssistantStore } from '@/app/stores/home-assistant-store';
66
import { getStoryDocsDescription } from '@/app/storybook/story-docs';
7-
import { buildCustomCard, CustomWidgetStoryFrame } from '../stories/custom-card-story-helpers';
7+
import { buildCustomCard, CustomWidgetStoryFrame } from '@/app/storybook/story-frames';
88

99
const sampleTrackerEntities: HassEntities = {
1010
'person.alice': {

src/app/features/dashboard/components/custom-card-photo-frame.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22
import type { CardSize } from '@/app/components/shared/card-size-selector';
33
import { getStoryDocsDescription } from '@/app/storybook/story-docs';
4-
import { buildCustomCard, CustomWidgetStoryFrame } from '../stories/custom-card-story-helpers';
4+
import { buildCustomCard, CustomWidgetStoryFrame } from '@/app/storybook/story-frames';
55

66
type PhotoStoryArgs = {
77
size: CardSize;

src/app/features/dashboard/components/custom-card-quick-note.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22
import type { CardSize } from '@/app/components/shared/card-size-selector';
33
import { getStoryDocsDescription } from '@/app/storybook/story-docs';
4-
import { buildCustomCard, CustomWidgetStoryFrame } from '../stories/custom-card-story-helpers';
4+
import { buildCustomCard, CustomWidgetStoryFrame } from '@/app/storybook/story-frames';
55

66
type QuickNoteStoryArgs = {
77
size: Extract<CardSize, 'small' | 'medium' | 'large'>;
Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,20 @@
1-
import { useCallback, useEffect, useState } from 'react';
2-
import { STORAGE_KEYS } from '@/app/constants/storage-keys';
3-
import { storage } from '@/app/utils/storage';
1+
import { useCallback } from 'react';
2+
import { useCardZonesStore } from '../stores/card-zones-store';
43
import type { ZoneName } from '../zones/zone-types';
54

65
/**
7-
* Reads and writes per-card zone overrides from localStorage.
8-
* Follows the same pattern as useCardOrdering.
9-
*
106
* Zone overrides are stored separately from card records so that
117
* auto-discovered HA entity cards (which live only in the HA store)
128
* can also have explicit zone assignments.
139
*/
1410
export function useCardZones() {
15-
const [cardZones, setCardZones] = useState<Record<string, ZoneName>>(() =>
16-
storage.get<Record<string, ZoneName>>(STORAGE_KEYS.cardZones, {})
17-
);
18-
19-
const updateCardZone = useCallback((id: string, zone: ZoneName) => {
20-
setCardZones((prev) => ({ ...prev, [id]: zone }));
21-
}, []);
11+
const cardZones = useCardZonesStore((state) => state.cardZones);
12+
const updateStoredCardZone = useCardZonesStore((state) => state.updateCardZone);
2213

23-
useEffect(() => {
24-
storage.set(STORAGE_KEYS.cardZones, cardZones);
25-
}, [cardZones]);
14+
const updateCardZone = useCallback(
15+
(id: string, zone: ZoneName) => updateStoredCardZone(id, zone),
16+
[updateStoredCardZone]
17+
);
2618

2719
return { cardZones, updateCardZone };
2820
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
export type { AllViewGrouping } from './all-view-grid';
22
export { DashboardCardItem } from './components/dashboard-card-item';
33
export { DashboardHeroSection } from './components/dashboard-hero-section';
4+
export { WidgetCard } from './components/widget-card';
45
export { useDashboardWidgetRoomOptions } from './components/widgets/use-widget-room-options';
56
export { getDashboardWidgetSurfaceTokens } from './components/widgets/widget-surface-tokens';
67
export { DashboardPage } from './page';
78
export { DashboardLayout } from './shell';
89
export {
10+
type CustomCard,
911
ENERGY_WIDGET_ROOM,
1012
HOME_WIDGET_ROOM,
1113
useCustomCardsStore,
1214
} from './stores/custom-cards-store';
1315
export { useDashboardEntitiesStore } from './stores/dashboard-entities-store';
14-
export { renderCard } from './utils/card-renderer';
16+
export { DASHBOARD_CARD_TYPES, renderCard } from './utils/card-renderer';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import { STORAGE_KEYS } from '@/app/constants/storage-keys';
3+
import { useCardZonesStore } from '../card-zones-store';
4+
5+
describe('useCardZonesStore', () => {
6+
beforeEach(() => {
7+
localStorage.clear();
8+
useCardZonesStore.setState(useCardZonesStore.getInitialState(), true);
9+
});
10+
11+
it('updates and persists card zone assignments', () => {
12+
useCardZonesStore.getState().updateCardZone('light.kitchen', 'actions');
13+
14+
expect(useCardZonesStore.getState().cardZones).toEqual({
15+
'light.kitchen': 'actions',
16+
});
17+
expect(localStorage.getItem(STORAGE_KEYS.cardZones)).toContain('light.kitchen');
18+
});
19+
20+
it('hydrates legacy raw records and drops invalid zones', async () => {
21+
localStorage.setItem(
22+
STORAGE_KEYS.cardZones,
23+
JSON.stringify({
24+
'weather.home': 'hero',
25+
'sensor.power': 'analytics',
26+
'button.bad': 'invalid',
27+
nested: { zone: 'status' },
28+
})
29+
);
30+
31+
await useCardZonesStore.persist.rehydrate();
32+
33+
expect(useCardZonesStore.getState().cardZones).toEqual({
34+
'weather.home': 'hero',
35+
'sensor.power': 'analytics',
36+
});
37+
});
38+
39+
it('hydrates persisted Zustand records and drops non-string ids', async () => {
40+
localStorage.setItem(
41+
STORAGE_KEYS.cardZones,
42+
JSON.stringify({
43+
state: {
44+
cardZones: {
45+
'vacuum.downstairs': 'status',
46+
'media.living_room': 'hero',
47+
'light.invalid': 'left',
48+
},
49+
},
50+
version: 0,
51+
})
52+
);
53+
54+
await useCardZonesStore.persist.rehydrate();
55+
56+
expect(useCardZonesStore.getState().cardZones).toEqual({
57+
'vacuum.downstairs': 'status',
58+
'media.living_room': 'hero',
59+
});
60+
});
61+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { create } from 'zustand';
2+
import { type PersistStorage, persist } from 'zustand/middleware';
3+
import { STORAGE_KEYS } from '@/app/constants/storage-keys';
4+
import { ZONE_ORDERED, type ZoneName } from '../zones/zone-types';
5+
6+
interface CardZonesStore {
7+
cardZones: Record<string, ZoneName>;
8+
updateCardZone: (id: string, zone: ZoneName) => void;
9+
}
10+
11+
type PersistedCardZonesState = Pick<CardZonesStore, 'cardZones'>;
12+
13+
const VALID_ZONES = new Set<ZoneName>(ZONE_ORDERED);
14+
15+
const cardZonesStorage: PersistStorage<PersistedCardZonesState> = {
16+
getItem: (name) => {
17+
const item = localStorage.getItem(name);
18+
if (!item) {
19+
return null;
20+
}
21+
22+
try {
23+
const parsed = JSON.parse(item) as unknown;
24+
25+
if (parsed && typeof parsed === 'object' && 'state' in parsed) {
26+
return parsed as { state: PersistedCardZonesState; version?: number };
27+
}
28+
29+
return {
30+
state: {
31+
cardZones: normalizeCardZones(parsed),
32+
},
33+
version: 0,
34+
};
35+
} catch {
36+
return null;
37+
}
38+
},
39+
setItem: (name, value) => {
40+
localStorage.setItem(name, JSON.stringify(value));
41+
},
42+
removeItem: (name) => {
43+
localStorage.removeItem(name);
44+
},
45+
};
46+
47+
function normalizeCardZones(value: unknown): Record<string, ZoneName> {
48+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
49+
return {};
50+
}
51+
52+
return Object.fromEntries(
53+
Object.entries(value).filter(
54+
(entry): entry is [string, ZoneName] =>
55+
typeof entry[0] === 'string' &&
56+
typeof entry[1] === 'string' &&
57+
VALID_ZONES.has(entry[1] as ZoneName)
58+
)
59+
);
60+
}
61+
62+
function getPersistedCardZones(value: unknown): Record<string, ZoneName> {
63+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
64+
return {};
65+
}
66+
67+
if ('cardZones' in value) {
68+
return normalizeCardZones((value as { cardZones?: unknown }).cardZones);
69+
}
70+
71+
return normalizeCardZones(value);
72+
}
73+
74+
export const useCardZonesStore = create<CardZonesStore>()(
75+
persist(
76+
(set) => ({
77+
cardZones: {},
78+
updateCardZone: (id, zone) =>
79+
set((state) => ({
80+
cardZones: {
81+
...state.cardZones,
82+
[id]: zone,
83+
},
84+
})),
85+
}),
86+
{
87+
name: STORAGE_KEYS.cardZones,
88+
storage: cardZonesStorage,
89+
partialize: (state) => ({ cardZones: state.cardZones }),
90+
merge: (persisted, current) => ({
91+
...current,
92+
cardZones: getPersistedCardZones(persisted),
93+
}),
94+
}
95+
)
96+
);

0 commit comments

Comments
 (0)