Newer
Older
smart-home-server / webclient-vue / src / stores / areas.js
import { ref, computed, watch } from "vue";
import { defineStore } from "pinia";
import { areasApi } from "../api/modules/areas";
import { useAsyncRequest } from "../composables/useAsyncRequest";

const EXPANDED_NODES_KEY = "sh:areas:expandedNodes";

function loadExpandedNodes() {
  try {
    const raw = localStorage.getItem(EXPANDED_NODES_KEY);
    if (raw) {
      const ids = JSON.parse(raw);
      if (Array.isArray(ids)) {
        return new Set(ids);
      }
    }
  } catch {
    // ignore corrupt storage
  }
  return new Set();
}

function saveExpandedNodes(set) {
  try {
    localStorage.setItem(EXPANDED_NODES_KEY, JSON.stringify([...set]));
  } catch {
    // ignore storage errors
  }
}

function buildAreaTree(areas) {
  const map = {};
  const roots = [];

  for (const area of areas) {
    map[area.id] = { ...area, children: [] };
  }

  for (const area of areas) {
    const node = map[area.id];
    const isSelfReference = area.parent_id && area.parent_id == area.id;
    const parentExists = area.parent_id && map[area.parent_id];

    if (!isSelfReference && parentExists) {
      map[area.parent_id].children.push(node);
    } else {
      roots.push(node);
    }
  }

  if (roots.length === 0 && areas.length > 0) {
    return Object.values(map);
  }

  return roots;
}

export const useAreasStore = defineStore("areas", () => {
  const areas = ref([]);
  const currentArea = ref(null);
  const currentAreaDevices = ref([]);
  const currentAreaScripts = ref([]);

  const listRequest = useAsyncRequest();
  const detailRequest = useAsyncRequest();

  const areasById = computed(() =>
    Object.fromEntries(areas.value.map((area) => [String(area.id), area]))
  );

  const areaTree = computed(() => buildAreaTree(areas.value));

  const expandedNodeIds = ref(loadExpandedNodes());

  watch(
    expandedNodeIds,
    (next) => {
      saveExpandedNodes(next);
    },
    { deep: true }
  );

  function toggleNode(id) {
    const next = new Set(expandedNodeIds.value);
    if (next.has(id)) {
      next.delete(id);
    } else {
      next.add(id);
    }
    expandedNodeIds.value = next;
  }

  function isNodeExpanded(id) {
    return expandedNodeIds.value.has(id);
  }

  async function loadAreas() {
    return listRequest.execute(async (signal) => {
      const result = await areasApi.list({ signal });
      if (result.ok) {
        areas.value = result.data?.data?.areas || [];
      }
      return result;
    });
  }

  async function loadAreaDetail(areaId) {
    detailRequest.abort();
    const controller = new AbortController();
    detailRequest.abortController.value = controller;

    detailRequest.isLoading.value = true;
    detailRequest.error.value = null;
    currentArea.value = areasById.value[String(areaId)] || null;
    currentAreaDevices.value = [];
    currentAreaScripts.value = [];

    try {
      const [devicesResult, scriptsResult] = await Promise.all([
        areasApi.devices(areaId, { signal: controller.signal }),
        areasApi.scripts(areaId, { signal: controller.signal }),
      ]);

      detailRequest.abortController.value = null;
      detailRequest.isLoading.value = false;

      if (!devicesResult.ok) {
        if (devicesResult.error?.type !== "timeout") {
          detailRequest.error.value = devicesResult.error;
        }
        return { ok: false, error: detailRequest.error.value };
      }

      if (!scriptsResult.ok) {
        if (scriptsResult.error?.type !== "timeout") {
          detailRequest.error.value = scriptsResult.error;
        }
        return { ok: false, error: detailRequest.error.value };
      }

      currentAreaDevices.value = devicesResult.data?.data?.devices || [];
      currentAreaScripts.value = scriptsResult.data?.data?.scripts || [];
      return { ok: true };
    } catch (err) {
      detailRequest.abortController.value = null;
      detailRequest.isLoading.value = false;
      detailRequest.error.value = {
        type: "network_error",
        message: err?.message || "Network error",
      };
      return { ok: false, error: detailRequest.error.value };
    }
  }

  function clearAreaDetail() {
    currentArea.value = null;
    currentAreaDevices.value = [];
    currentAreaScripts.value = [];
    detailRequest.clear();
  }

  async function loadAreaDevices(areaId) {
    const result = await areasApi.devices(areaId);
    if (result.ok) {
      currentAreaDevices.value = result.data?.data?.devices || [];
    }
    return result;
  }

  async function loadAreaScripts(areaId) {
    const result = await areasApi.scripts(areaId);
    if (result.ok) {
      currentAreaScripts.value = result.data?.data?.scripts || [];
    }
    return result;
  }

  async function createArea(payload) {
    const result = await areasApi.newArea(payload);

    if (result.ok) {
      const newArea = result.data?.data?.area;
      if (newArea) {
        areas.value.push(newArea);
      }
    }

    return result;
  }

  async function renameArea(areaId, displayName) {
    const result = await areasApi.updateDisplayName({
      area_id: areaId,
      display_name: displayName,
    });

    if (result.ok) {
      const idx = areas.value.findIndex((a) => a.id === areaId);
      if (idx !== -1) {
        areas.value[idx] = { ...areas.value[idx], display_name: displayName };
      }
    }

    return result;
  }

  async function removeArea(areaId) {
    const result = await areasApi.remove(areaId);

    if (result.ok) {
      areas.value = areas.value.filter((a) => a.id !== areaId);
    }

    return result;
  }

  function isRootArea(area) {
    return !area?.parent_id || area.parent_id <= 0;
  }

  async function assignToArea(areaId, parentAreaId) {
    const target = areas.value.find((a) => a.id === areaId);
    const rootCount = areas.value.filter(isRootArea).length;

    if (target && isRootArea(target) && rootCount === 1) {
      return {
        ok: false,
        error: { message: "Cannot assign the last root area as a child." },
      };
    }

    const result = await areasApi.placeInArea({
      target_id: areaId,
      place_in_area_id: parentAreaId,
    });

    if (result.ok) {
      const idx = areas.value.findIndex((a) => a.id === areaId);
      if (idx !== -1) {
        areas.value.splice(idx, 1, {
          ...areas.value[idx],
          parent_id: Number(parentAreaId),
        });
      }
    }

    return result;
  }

  async function unassignArea(areaId) {
    const result = await areasApi.unassign(areaId);

    if (result.ok) {
      const idx = areas.value.findIndex((a) => a.id === areaId);
      if (idx !== -1) {
        areas.value.splice(idx, 1, { ...areas.value[idx], parent_id: 0 });
      }
    }

    return result;
  }

  return {
    areas,
    isLoading: listRequest.isLoading,
    error: listRequest.error,
    currentArea,
    currentAreaDevices,
    currentAreaScripts,
    isLoadingAreaDetail: detailRequest.isLoading,
    errorAreaDetail: detailRequest.error,
    areasById,
    areaTree,
    expandedNodeIds,
    toggleNode,
    isNodeExpanded,
    loadAreas,
    loadAreaDetail,
    clearAreaDetail,
    loadAreaDevices,
    loadAreaScripts,
    createArea,
    renameArea,
    removeArea,
    assignToArea,
    unassignArea,
  };
});