locationSensorDefaults.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import { useEffect, useState } from 'react';
  2. import type { LocationHASensorReading } from '../api/client';
  3. export type LocationSensorCategory = 'temperature' | 'humidity' | 'battery';
  4. export interface LocationSensorCategoryDefaults {
  5. alertAbove: string;
  6. alertBelow: string;
  7. notifyOnAlert: boolean;
  8. showOnCard: boolean;
  9. }
  10. export type LocationSensorDefaults = Record<LocationSensorCategory, LocationSensorCategoryDefaults>;
  11. // The alert fields (alertAbove/alertBelow/notifyOnAlert) live on the server in
  12. // the `location_sensor_alert_defaults` setting, not here: they seed the alert
  13. // rule written onto each sensor row, so two admins binding sensors from
  14. // different browsers must not seed different rules, and a backup has to carry
  15. // them. `showOnCard` stays per-browser — show_on_card is decided per sensor and
  16. // this is only the form's pre-selection, not a rule the installation runs on.
  17. const SHOW_ON_CARD_STORAGE_KEY = 'bambuddy-location-sensor-show-on-card-defaults';
  18. const EMPTY_CATEGORY_DEFAULTS: LocationSensorCategoryDefaults = {
  19. alertAbove: '',
  20. alertBelow: '',
  21. notifyOnAlert: false,
  22. showOnCard: true,
  23. };
  24. export function defaultLocationSensorDefaults(): LocationSensorDefaults {
  25. return {
  26. temperature: { ...EMPTY_CATEGORY_DEFAULTS, alertAbove: '30', alertBelow: '20' },
  27. humidity: { ...EMPTY_CATEGORY_DEFAULTS, alertAbove: '30', alertBelow: '10' },
  28. battery: { ...EMPTY_CATEGORY_DEFAULTS, alertBelow: '10' },
  29. };
  30. }
  31. // Only the three alert fields are read off the server value; anything else in
  32. // the stored JSON is ignored so a hand-edited setting cannot inject keys.
  33. type StoredAlertDefaults = Partial<Record<LocationSensorCategory, Partial<LocationSensorCategoryDefaults>>>;
  34. function readShowOnCardDefaults(): Partial<Record<LocationSensorCategory, boolean>> {
  35. try {
  36. const stored = localStorage.getItem(SHOW_ON_CARD_STORAGE_KEY);
  37. return stored ? (JSON.parse(stored) as Partial<Record<LocationSensorCategory, boolean>>) : {};
  38. } catch {
  39. return {};
  40. }
  41. }
  42. /**
  43. * Merge built-in defaults, the server's alert defaults and the local
  44. * show-on-card preference into one shape for the forms.
  45. *
  46. * Pass the `location_sensor_alert_defaults` string from the settings query.
  47. * Omitting it (or passing an empty string) yields the built-in defaults, which
  48. * is exactly what an installation that has never opened the options dialog
  49. * gets — no migration needed.
  50. */
  51. export function loadLocationSensorDefaults(alertDefaultsJson?: string | null): LocationSensorDefaults {
  52. const defaults = defaultLocationSensorDefaults();
  53. if (alertDefaultsJson) {
  54. try {
  55. const parsed = JSON.parse(alertDefaultsJson) as StoredAlertDefaults;
  56. (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
  57. const stored = parsed[category];
  58. if (!stored) return;
  59. if (typeof stored.alertAbove === 'string') defaults[category].alertAbove = stored.alertAbove;
  60. if (typeof stored.alertBelow === 'string') defaults[category].alertBelow = stored.alertBelow;
  61. if (typeof stored.notifyOnAlert === 'boolean') defaults[category].notifyOnAlert = stored.notifyOnAlert;
  62. });
  63. } catch {
  64. // A corrupted setting falls back to the built-ins rather than blocking
  65. // the dialog — same posture as the localStorage readers below.
  66. }
  67. }
  68. const showOnCard = readShowOnCardDefaults();
  69. (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
  70. if (typeof showOnCard[category] === 'boolean') defaults[category].showOnCard = showOnCard[category];
  71. });
  72. return defaults;
  73. }
  74. /** The value to PATCH into `location_sensor_alert_defaults`. */
  75. export function serializeLocationSensorAlertDefaults(defaults: LocationSensorDefaults): string {
  76. const out: StoredAlertDefaults = {};
  77. (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
  78. out[category] = {
  79. alertAbove: defaults[category].alertAbove,
  80. alertBelow: defaults[category].alertBelow,
  81. notifyOnAlert: defaults[category].notifyOnAlert,
  82. };
  83. });
  84. return JSON.stringify(out);
  85. }
  86. export function saveLocationSensorShowOnCardDefaults(defaults: LocationSensorDefaults) {
  87. try {
  88. const out: Partial<Record<LocationSensorCategory, boolean>> = {};
  89. (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
  90. out[category] = defaults[category].showOnCard;
  91. });
  92. localStorage.setItem(SHOW_ON_CARD_STORAGE_KEY, JSON.stringify(out));
  93. } catch {
  94. return;
  95. }
  96. }
  97. const COLORIZE_VALUES_STORAGE_KEY = 'bambuddy-location-sensor-colorize-values';
  98. export function loadLocationSensorColorizeValues(): boolean {
  99. try {
  100. const stored = localStorage.getItem(COLORIZE_VALUES_STORAGE_KEY);
  101. return stored ? stored === 'true' : true;
  102. } catch {
  103. return true;
  104. }
  105. }
  106. // A plain localStorage.setItem never fires the browser's own 'storage'
  107. // event in the tab that made the change (only other tabs get that), and
  108. // these four values are read by an unknown number of already-mounted
  109. // components (the Inventory page, and one SpoolLocationFooter per card).
  110. // This lets useLocationSensorColorPrefs below re-read after a save instead
  111. // of every reader needing its own poll or the page needing a reload.
  112. const COLOR_PREFS_CHANGED_EVENT = 'bambuddy:location-sensor-color-prefs-changed';
  113. function notifyLocationSensorColorPrefsChanged() {
  114. window.dispatchEvent(new Event(COLOR_PREFS_CHANGED_EVENT));
  115. }
  116. export function saveLocationSensorColorizeValues(value: boolean) {
  117. try {
  118. localStorage.setItem(COLORIZE_VALUES_STORAGE_KEY, String(value));
  119. } catch {
  120. return;
  121. }
  122. notifyLocationSensorColorPrefsChanged();
  123. }
  124. export const LOCATION_SENSOR_ALERT_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'] as const;
  125. export type LocationSensorAlertColor = (typeof LOCATION_SENSOR_ALERT_COLORS)[number];
  126. export const LOCATION_SENSOR_ALERT_COLOR_CLASSES: Record<LocationSensorAlertColor, string> = {
  127. red: 'text-red-400',
  128. orange: 'text-orange-400',
  129. yellow: 'text-yellow-400',
  130. green: 'text-green-400',
  131. blue: 'text-blue-400',
  132. purple: 'text-purple-400',
  133. pink: 'text-pink-400',
  134. };
  135. const ALERT_ABOVE_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-above-color';
  136. const ALERT_BELOW_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-below-color';
  137. const ALERT_OPTIMAL_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-optimal-color';
  138. function isAlertColor(value: string | null): value is LocationSensorAlertColor {
  139. return !!value && (LOCATION_SENSOR_ALERT_COLORS as readonly string[]).includes(value);
  140. }
  141. export function loadLocationSensorAlertAboveColor(): LocationSensorAlertColor {
  142. try {
  143. const stored = localStorage.getItem(ALERT_ABOVE_COLOR_STORAGE_KEY);
  144. return isAlertColor(stored) ? stored : 'purple';
  145. } catch {
  146. return 'purple';
  147. }
  148. }
  149. export function saveLocationSensorAlertAboveColor(color: LocationSensorAlertColor) {
  150. try {
  151. localStorage.setItem(ALERT_ABOVE_COLOR_STORAGE_KEY, color);
  152. } catch {
  153. return;
  154. }
  155. notifyLocationSensorColorPrefsChanged();
  156. }
  157. export function loadLocationSensorAlertBelowColor(): LocationSensorAlertColor {
  158. try {
  159. const stored = localStorage.getItem(ALERT_BELOW_COLOR_STORAGE_KEY);
  160. return isAlertColor(stored) ? stored : 'red';
  161. } catch {
  162. return 'red';
  163. }
  164. }
  165. export function saveLocationSensorAlertBelowColor(color: LocationSensorAlertColor) {
  166. try {
  167. localStorage.setItem(ALERT_BELOW_COLOR_STORAGE_KEY, color);
  168. } catch {
  169. return;
  170. }
  171. notifyLocationSensorColorPrefsChanged();
  172. }
  173. export function loadLocationSensorAlertOptimalColor(): LocationSensorAlertColor {
  174. try {
  175. const stored = localStorage.getItem(ALERT_OPTIMAL_COLOR_STORAGE_KEY);
  176. return isAlertColor(stored) ? stored : 'green';
  177. } catch {
  178. return 'green';
  179. }
  180. }
  181. export function saveLocationSensorAlertOptimalColor(color: LocationSensorAlertColor) {
  182. try {
  183. localStorage.setItem(ALERT_OPTIMAL_COLOR_STORAGE_KEY, color);
  184. } catch {
  185. return;
  186. }
  187. notifyLocationSensorColorPrefsChanged();
  188. }
  189. export interface LocationSensorColorPrefs {
  190. colorize: boolean;
  191. aboveColor: LocationSensorAlertColor;
  192. belowColor: LocationSensorAlertColor;
  193. optimalColor: LocationSensorAlertColor;
  194. }
  195. function readLocationSensorColorPrefs(): LocationSensorColorPrefs {
  196. return {
  197. colorize: loadLocationSensorColorizeValues(),
  198. aboveColor: loadLocationSensorAlertAboveColor(),
  199. belowColor: loadLocationSensorAlertBelowColor(),
  200. optimalColor: loadLocationSensorAlertOptimalColor(),
  201. };
  202. }
  203. // Reads the four location-sensor colour preferences once and stays live:
  204. // a Settings save dispatches COLOR_PREFS_CHANGED_EVENT, and every mounted
  205. // caller of this hook (the Inventory page, previously also every
  206. // SpoolLocationFooter individually) picks it up without a reload. Callers
  207. // should read these values here and pass them down as props rather than
  208. // each calling this hook themselves — one subscription per page, not one
  209. // per card.
  210. export function useLocationSensorColorPrefs(): LocationSensorColorPrefs {
  211. const [prefs, setPrefs] = useState<LocationSensorColorPrefs>(readLocationSensorColorPrefs);
  212. useEffect(() => {
  213. const onChange = () => setPrefs(readLocationSensorColorPrefs());
  214. window.addEventListener(COLOR_PREFS_CHANGED_EVENT, onChange);
  215. return () => window.removeEventListener(COLOR_PREFS_CHANGED_EVENT, onChange);
  216. }, []);
  217. return prefs;
  218. }
  219. export type LocationSensorAlertStatus = 'above' | 'below' | 'ok' | null;
  220. export function locationSensorReadingAlertStatus(reading: LocationHASensorReading): LocationSensorAlertStatus {
  221. if (!reading.reachable || reading.state === null) return null;
  222. if (reading.kind === 'numeric') {
  223. if (reading.alert_above === null && reading.alert_below === null) return null;
  224. if (reading.value === null) return null;
  225. if (reading.alert_above !== null && reading.value > reading.alert_above) return 'above';
  226. if (reading.alert_below !== null && reading.value < reading.alert_below) return 'below';
  227. return 'ok';
  228. }
  229. if (reading.alert_state === null) return null;
  230. return reading.state.toLowerCase() === reading.alert_state ? 'above' : 'ok';
  231. }
  232. export function locationSensorValueColorClass(
  233. status: LocationSensorAlertStatus,
  234. aboveColor: LocationSensorAlertColor,
  235. belowColor: LocationSensorAlertColor,
  236. optimalColor: LocationSensorAlertColor
  237. ): string {
  238. if (status === 'above') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[aboveColor];
  239. if (status === 'below') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[belowColor];
  240. if (status === 'ok') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[optimalColor];
  241. return '';
  242. }