// Data store: in-memory state + localStorage persistence.

const STORAGE_KEY = 'taskmgr.v3';

// Field type catalog (Notion-style).
const FIELD_TYPE_CATALOG = [
  { type: 'text',         label: 'Text',         icon: 'T',  hint: 'Plain text' },
  { type: 'number',       label: 'Number',       icon: '#',  hint: 'Integers or decimals' },
  { type: 'select',       label: 'Select',       icon: '◉',  hint: 'One of a list' },
  { type: 'multi_select', label: 'Multi-select', icon: '⊞',  hint: 'Multiple tags' },
  { type: 'date',         label: 'Date',         icon: '📅', hint: 'Calendar date' },
  { type: 'checkbox',     label: 'Checkbox',     icon: '☑',  hint: 'Yes / No' },
  { type: 'url',          label: 'URL',          icon: '🔗', hint: 'Web link' },
  { type: 'email',        label: 'Email',        icon: '✉',  hint: 'Email address' },
  { type: 'phone',        label: 'Phone',        icon: '☏',  hint: 'Phone number' },
  { type: 'person',       label: 'Person',       icon: '👤', hint: 'Free-text name' },
  { type: 'duration',     label: 'Duration',     icon: '⏱',  hint: 'e.g. 2h 30m' },
];
const FIELD_TYPES = FIELD_TYPE_CATALOG.map(f => f.type);

// Curated pastel palette used for ALL color-pickable things (priority, field labels, etc).
// Picked to be harmonious + easy on the eyes; muted, not saturated.
const COLOR_PALETTE = [
  { id: 'rose',    label: 'Rose',    color: '#c97c7c' },
  { id: 'coral',   label: 'Coral',   color: '#d48868' },
  { id: 'amber',   label: 'Amber',   color: '#c69540' },
  { id: 'olive',   label: 'Olive',   color: '#8a9a5b' },
  { id: 'sage',    label: 'Sage',    color: '#7a9a7e' },
  { id: 'teal',    label: 'Teal',    color: '#5e9b9b' },
  { id: 'sky',     label: 'Sky',     color: '#7a9bc4' },
  { id: 'indigo',  label: 'Indigo',  color: '#8a86c4' },
  { id: 'plum',    label: 'Plum',    color: '#a684b0' },
  { id: 'mauve',   label: 'Mauve',   color: '#b88a9e' },
  { id: 'stone',   label: 'Stone',   color: '#9a948a' },
  { id: 'slate',   label: 'Slate',   color: '#6b7a8a' },
];
const PALETTE_COLORS = COLOR_PALETTE.map(c => c.color);

// Default priority palette — editable per project, drawn from COLOR_PALETTE
const DEFAULT_PRIORITY_COLORS = {
  High: '#c97c7c', // rose
  Med:  '#c69540', // amber
  Low:  '#9a948a', // stone
};

function makeBuiltinFields() {
  return [
    { id: 'priority',  name: 'Priority',  type: 'select', options: ['High','Med','Low'], builtin: true, showOnCard: true,  color: '#c97c7c' },
    { id: 'start',     name: 'Start',     type: 'date',   builtin: true, showOnCard: false, color: '#7a9a7e' },
    { id: 'due',       name: 'Due',       type: 'date',   builtin: true, showOnCard: true,  color: '#7a9bc4' },
  ];
}

function seedData() {
  return {
    activeProjectId: 'wisp',
    activeView: 'board',
    activeTaskId: null,
    settings: { density: 'comfortable', sidebarCollapsed: false },
    projects: [
      {
        id: 'wisp', name: 'Wisp · short film', color: '#c97c7c',
        priorityColors: { ...DEFAULT_PRIORITY_COLORS },
        stages: [
          { id: 's1', name: 'Backlog', colorId: 'none' },
          { id: 's2', name: 'Boards',  colorId: 'blue' },
          { id: 's3', name: 'Anim',    colorId: 'orange' },
          { id: 's4', name: 'Render',  colorId: 'purple' },
          { id: 's5', name: 'Comp',    colorId: 'teal' },
          { id: 's6', name: 'Approved',colorId: 'green' },
        ],
        fields: [
          ...makeBuiltinFields(),
          { id: 'scene',    name: 'Scene',    type: 'select', options: ['sc 01','sc 02','sc 03','sc 04','sc 05'], showOnCard: true, color: '#7a9bc4' },
          { id: 'shotType', name: 'Shot type',type: 'select', options: ['wide','MS','CU','insert'], showOnCard: false, color: '#7a9a7e' },
          { id: 'rig',      name: 'Rig used', type: 'select', options: ['hare','wisp','env'], showOnCard: false, color: '#c69540' },
        ],
        groupBy: 'priority',
        tlGroupBy: 'none',
        filters: [],
      },
      {
        id: 'hare', name: 'Bouncing Hare', color: '#7a9a7e',
        priorityColors: { ...DEFAULT_PRIORITY_COLORS },
        stages: [
          { id: 'h1', name: 'Ideas',    colorId: 'gray' },
          { id: 'h2', name: 'Backlog',  colorId: 'none' },
          { id: 'h3', name: 'Up next',  colorId: 'blue' },
          { id: 'h4', name: 'Doing',    colorId: 'orange' },
          { id: 'h5', name: 'Playtest', colorId: 'yellow' },
          { id: 'h6', name: 'Shipped',  colorId: 'green' },
        ],
        fields: [
          ...makeBuiltinFields(),
          { id: 'area',     name: 'Area',     type: 'select', options: ['gameplay','art','ui','audio','engine'], showOnCard: true, color: '#7a9bc4' },
          { id: 'level',    name: 'Level',    type: 'select', options: ['lvl 1','lvl 2','lvl 3','meta'], showOnCard: false, color: '#7a9a7e' },
          { id: 'risk',     name: 'Risk',     type: 'select', options: ['low','med','high'], showOnCard: false, color: '#c97c7c' },
        ],
        groupBy: 'priority',
        tlGroupBy: 'none',
        filters: [],
      },
      {
        id: 'reel', name: 'Reel cut 2026', color: '#c69540',
        priorityColors: { ...DEFAULT_PRIORITY_COLORS },
        stages: [
          { id: 'r1', name: 'Pick clips', colorId: 'none' },
          { id: 'r2', name: 'Cut',        colorId: 'blue' },
          { id: 'r3', name: 'Polish',     colorId: 'purple' },
          { id: 'r4', name: 'Done',       colorId: 'green' },
        ],
        fields: [...makeBuiltinFields()],
        groupBy: 'none',
        tlGroupBy: 'none',
        filters: [],
      },
    ],
    tasks: [
      // Wisp
      { id: 't1', projectId: 'wisp', stageId: 's3', title: 'Spline sc 01 walk',
        notes: "Get the contact pose feeling like she's pushing off — heel lift before toe-off.\nRef: Williams p114, hare ref clip 02.\nAvoid floaty arms on swing — overlap delay 2-3f.",
        values: { priority: 'High', due: '2026-05-01', start: '2026-04-27', est: '4h', scene: 'sc 01', shotType: 'MS', rig: 'hare' },
        subtasks: [
          { id: 'st1', text: 'Block contact poses', done: true },
          { id: 'st2', text: 'Splining + ease pass', done: true },
          { id: 'st3', text: 'Arm overlap', done: true },
          { id: 'st4', text: 'Polish breathing + weight', done: false },
        ],
        depsIn: ['t3'], depsOut: ['t8'],
        order: 0,
      },
      { id: 't2', projectId: 'wisp', stageId: 's3', title: 'Sc 01 CU reaction',
        values: { priority: 'Med', scene: 'sc 01', shotType: 'CU', rig: 'hare' },
        subtasks: [], depsIn: [], depsOut: [], order: 1 },
      { id: 't3', projectId: 'wisp', stageId: 's2', title: 'Block sc 02',
        values: { priority: 'High', start: '2026-05-02', due: '2026-05-08', est: '6h', scene: 'sc 02', shotType: 'MS', rig: 'hare' },
        subtasks: [
          { id: 'st5', text: 'Rough thumbs', done: false },
          { id: 'st6', text: 'Block keys', done: false },
        ],
        depsIn: [], depsOut: [], order: 0 },
      { id: 't4', projectId: 'wisp', stageId: 's2', title: 'Boards sc 03 polish',
        values: { priority: 'Med', scene: 'sc 03' }, subtasks: [], depsIn: [], depsOut: [], order: 1 },
      { id: 't5', projectId: 'wisp', stageId: 's1', title: 'Pick foliage ref',
        values: { priority: 'Low', scene: 'sc 03' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 't6', projectId: 'wisp', stageId: 's1', title: 'Storyboard sc 4 — chase',
        values: { priority: 'Med', scene: 'sc 04' }, subtasks: [], depsIn: [], depsOut: [], order: 1 },
      { id: 't7', projectId: 'wisp', stageId: 's4', title: 'Render test sc 01',
        values: { priority: 'Low', start: '2026-05-04', due: '2026-05-05', est: '1h', scene: 'sc 01' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 't8', projectId: 'wisp', stageId: 's5', title: 'Comp sc 01 wide',
        values: { priority: 'Med', start: '2026-05-06', due: '2026-05-09', scene: 'sc 01', shotType: 'wide' }, subtasks: [], depsIn: ['t1'], depsOut: [], order: 0 },
      { id: 't9', projectId: 'wisp', stageId: 's6', title: 'Sc 01 layout approved',
        values: { priority: 'Low', scene: 'sc 01' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },

      // Hare
      { id: 'h_t1', projectId: 'hare', stageId: 'h4', title: 'Tune jump curve',
        values: { priority: 'High', est: '2h', area: 'gameplay', level: 'lvl 1' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'h_t2', projectId: 'hare', stageId: 'h4', title: 'Implement double-jump',
        values: { priority: 'High', est: '2h', area: 'gameplay', level: 'lvl 1' }, subtasks: [], depsIn: ['h_t1'], depsOut: [], order: 1 },
      { id: 'h_t3', projectId: 'hare', stageId: 'h3', title: 'Boss arena layout',
        values: { priority: 'Med', est: '4h', area: 'art', level: 'lvl 2' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'h_t4', projectId: 'hare', stageId: 'h3', title: 'Refactor input map',
        values: { priority: 'Low', area: 'engine', risk: 'low' }, subtasks: [], depsIn: [], depsOut: [], order: 1 },
      { id: 'h_t5', projectId: 'hare', stageId: 'h2', title: 'Save system',
        values: { priority: 'Med', area: 'engine', risk: 'med' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'h_t6', projectId: 'hare', stageId: 'h2', title: 'Pause menu',
        values: { priority: 'Low', area: 'ui', risk: 'low' }, subtasks: [], depsIn: [], depsOut: [], order: 1 },
      { id: 'h_t7', projectId: 'hare', stageId: 'h1', title: 'Co-op idea',
        values: { priority: 'Low', area: 'gameplay', level: 'meta', risk: 'high' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'h_t8', projectId: 'hare', stageId: 'h5', title: 'Playtest build v3',
        values: { priority: 'High', area: 'gameplay' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'h_t9', projectId: 'hare', stageId: 'h6', title: 'SFX pass lvl 1',
        values: { priority: 'Low', area: 'audio', level: 'lvl 1' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },

      // Reel
      { id: 'r_t1', projectId: 'reel', stageId: 'r1', title: 'Pick clips for cut 3',
        values: { priority: 'Med', start: '2026-05-08', due: '2026-05-12' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
      { id: 'r_t2', projectId: 'reel', stageId: 'r2', title: 'Rough cut pass',
        values: { priority: 'Med', est: '3h' }, subtasks: [], depsIn: [], depsOut: [], order: 0 },
    ],
  };
}

// ─── Store implementation ────────────────────────────────
const listeners = new Set();
let state = null;
let activeBackend = 'local'; // 'server' | 'electron' | 'local'
let activeWorkspaceName = null;
let unsubscribeSSE = null;

// ── Per-browser UI/navigation state ─────────────────────────────────────────
// Which project/view/task is open, and sidebar collapse, are NOT shared data
// — they're specific to what one person is looking at right now. Previously
// these lived inside `state` and got pushed to the server/other clients on
// every save, then pulled back down on every live-sync refetch: so opening a
// card on one machine forced every other logged-in machine to jump to that
// same card too (effectively screen-sharing navigation across all users).
// Keeping them in a separate, localStorage-only object — never sent to
// save(), never overwritten by applyRemoteState() — lets multiple people
// work the same workspace independently while still sharing the same data.
const UI_STORAGE_KEY = 'taskmgr.ui.v1';
let uiState = { activeProjectId: null, activeView: 'board', activeTaskId: null, sidebarCollapsed: false };
function loadUiState() {
  try {
    const raw = localStorage.getItem(UI_STORAGE_KEY);
    if (raw) uiState = { ...uiState, ...JSON.parse(raw) };
  } catch (e) {}
}
function saveUiState() {
  try { localStorage.setItem(UI_STORAGE_KEY, JSON.stringify(uiState)); }
  catch (e) {}
}
// Combined snapshot handed to components: shared `state` plus this browser's
// own navigation state layered on top.
function snapshot() {
  return {
    ...state,
    activeProjectId: uiState.activeProjectId,
    activeView: uiState.activeView,
    activeTaskId: uiState.activeTaskId,
    settings: { ...state.settings, sidebarCollapsed: uiState.sidebarCollapsed },
  };
}
function notify() {
  const snap = snapshot();
  listeners.forEach(fn => fn(snap));
}
// UI-only change: persist locally, notify this browser's components, but
// never touch the server/electron/localStorage DATA save — so it can't leak
// to (or be echoed back and forth with) other clients.
function emitUI() {
  saveUiState();
  notify();
}

// Sync path — Electron desktop file, or plain browser localStorage.
// Used when there is no server backend at this origin.
function initLocalOrElectron() {
  loadUiState();
  if (window.electronAPI) {
    activeBackend = 'electron';
    const data = window.electronAPI.loadData();
    state = migrate(data || seedData());
    if (uiState.activeProjectId == null) uiState.activeProjectId = state.projects[0]?.id ?? null;
    // Multi-machine sync: Google Drive (or similar) may sync down a change
    // another machine made to this same file while the app is open. Apply it
    // directly without re-saving (avoids a write loop).
    if (window.electronAPI.onExternalChange) {
      window.electronAPI.onExternalChange((data) => {
        if (!data) return;
        applyRemoteState(data);
        if (typeof showToast === 'function') showToast('Synced changes from another machine');
      });
    }
    return;
  }
  activeBackend = 'local';
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    state = migrate(raw ? JSON.parse(raw) : seedData());
  } catch (e) {
    console.warn('seed: storage parse failed', e);
    state = seedData();
  }
  if (uiState.activeProjectId == null) uiState.activeProjectId = state.projects[0]?.id ?? null;
}

// Async path — self-hosted server backend. Fetches the named workspace,
// subscribes to live-sync push updates from other clients, and (re)points
// save() at that workspace going forward.
async function initServerWorkspace(name) {
  loadUiState();
  activeBackend = 'server';
  activeWorkspaceName = name;
  const data = await window.serverAPI.loadData(name);
  state = migrate(data || seedData());
  if (uiState.activeProjectId == null || !state.projects.some(p => p.id === uiState.activeProjectId)) {
    uiState.activeProjectId = state.projects[0]?.id ?? null;
  }
  if (unsubscribeSSE) { unsubscribeSSE(); unsubscribeSSE = null; }
  unsubscribeSSE = window.serverAPI.subscribe(name, async () => {
    try {
      const fresh = await window.serverAPI.loadData(name);
      applyRemoteState(fresh || seedData());
    } catch (e) { console.warn('TonyPlanner: live sync refetch failed —', e); }
  });
}

// Applies state that arrived via live sync WITHOUT re-saving it back
// (avoids an infinite save <-> notify loop between clients). Only the
// shared DATA is replaced — this browser's own navigation (uiState) is left
// untouched, so a teammate's edit never yanks you away from what you're
// looking at.
function applyRemoteState(data) {
  state = migrate(data);
  notify();
}

function save() {
  if (activeBackend === 'server') {
    if (activeWorkspaceName) window.serverAPI.saveData(activeWorkspaceName, state);
    return;
  }
  if (activeBackend === 'electron') {
    window.electronAPI.saveData(state);
    return;
  }
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
  catch (e) { console.warn('save failed', e); }
}
function emit() {
  save();
  notify();
}
function subscribe(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

function migrate(s) {
  if (!s || !s.projects) return s;
  if (s.weekStartsMonday === undefined) s.weekStartsMonday = true;
  s.projects = s.projects.map(p => {
    const np = { ...p };
    if (!np.priorityColors) np.priorityColors = { ...DEFAULT_PRIORITY_COLORS };
    if (!np.filters) np.filters = [];
    if (!np.tlGroupBy) np.tlGroupBy = 'none';
    if (np.hideEpicCards === undefined) np.hideEpicCards = false;
    if (np.hideStoryCards === undefined) np.hideStoryCards = false;
    if (np.hideDoneCards === undefined) np.hideDoneCards = false;
    np.stages = (np.stages || []).map(st => st.colorId ? st : { ...st, colorId: 'none' });
    np.fields = (np.fields || [])
      .filter(f => f.id !== 'est' && f.id !== 'remaining')
      .map(f => ({
        showOnCard: f.builtin ? (f.id === 'priority' || f.id === 'due') : true,
        color: f.color || '#8a8780',
        ...f,
      }));
    return np;
  });
  // Stories created before auto-coloring existed (or ones that never had a
  // color assigned) fall back to the theme's neutral border color at render
  // time, which reads as near-black in the dark "sketch" skin — give every
  // colorless story a stable palette color here so it always shows a real hue.
  const storyColorCounts = {};
  s.tasks = (s.tasks || []).map(t => {
    if (t.isStory && !t.storyColor) {
      const n = storyColorCounts[t.projectId] || 0;
      storyColorCounts[t.projectId] = n + 1;
      t = { ...t, storyColor: PALETTE_COLORS[n % PALETTE_COLORS.length] };
    }
    return t;
  });
  // Epics created before the reorderable list existed have no epicOrder —
  // backfill it from their existing (creation-time) array order so the
  // parent-dropdown and grouped board keep today's ordering until someone
  // drags to change it in Settings.
  const epicOrderCounts = {};
  s.tasks = (s.tasks || []).map(t => {
    if (t.isEpic && t.epicOrder === undefined) {
      const n = epicOrderCounts[t.projectId] || 0;
      epicOrderCounts[t.projectId] = n + 1;
      t = { ...t, epicOrder: n };
    }
    return t;
  });
  // Same backfill for Stories.
  const storyOrderCounts = {};
  s.tasks = (s.tasks || []).map(t => {
    if (t.isStory && t.storyOrder === undefined) {
      const n = storyOrderCounts[t.projectId] || 0;
      storyOrderCounts[t.projectId] = n + 1;
      t = { ...t, storyOrder: n };
    }
    return t;
  });
  s.tasks = (s.tasks || []).map(t => {
    const values = { ...t.values };
    delete values.est; delete values.remaining;
    return {
      depsIn: t.depsIn || [],
      depsOut: t.depsOut || [],
      comments: t.comments || [],
      isEpic: false,
      epicId: null,
      epicColor: null,
      epicChildrenVisible: true,
      epicPos: null,
      isStory: false,
      storyId: null,
      storyColor: null,
      storyChildrenVisible: true,
      storyPos: null,
      done: false,
      ...t,
      values,
    };
  });
  // Repair mislinked parents: a child can end up with epicId pointing at a
  // task that's actually a Story (or storyId pointing at one that's actually
  // an Epic) if that parent was converted from one type to the other after
  // children were already linked. That leaves the parent-jump tag on the
  // card pointing at the right title (lookup is by id, type-agnostic) while
  // the task's own "Parent" field editor and the parent's hide-children
  // toggle both look at the wrong field and come up empty/no-op. Move the
  // link to the field matching what the parent actually is now.
  const byId = {};
  s.tasks.forEach(t => { byId[t.id] = t; });
  s.tasks = s.tasks.map(t => {
    if (t.epicId && !t.isEpic && !t.isStory) {
      const parent = byId[t.epicId];
      if (parent && parent.isStory) return { ...t, epicId: null, storyId: t.epicId };
    }
    if (t.storyId && !t.isEpic && !t.isStory) {
      const parent = byId[t.storyId];
      if (parent && parent.isEpic) return { ...t, storyId: null, epicId: t.storyId };
    }
    return t;
  });
  if (!s.settings) s.settings = { density: 'comfortable', sidebarCollapsed: false };
  if (s.settings.sidebarCollapsed === undefined) s.settings.sidebarCollapsed = false;
  return s;
}

const uid = (p='') => p + Math.random().toString(36).slice(2, 9);

function getActiveProject() { return state.projects.find(p => p.id === uiState.activeProjectId); }
function getProjectTasks(projectId) { return state.tasks.filter(t => t.projectId === projectId); }
function getTask(id) { return state.tasks.find(t => t.id === id); }

const actions = {
  setActiveProject(id) { uiState = { ...uiState, activeProjectId: id, activeView: uiState.activeView || 'board' }; emitUI(); },
  setActiveView(v)     { uiState = { ...uiState, activeView: v }; emitUI(); },
  setActiveTask(id)    { uiState = { ...uiState, activeTaskId: id }; emitUI(); },

  setSidebarCollapsed(v) {
    uiState = { ...uiState, sidebarCollapsed: !!v };
    emitUI();
  },

  setGroupBy(projectId, groupBy) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, groupBy } : p) };
    emit();
  },
  setHideEpicCards(projectId, hide) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, hideEpicCards: !!hide } : p) };
    emit();
  },
  setHideStoryCards(projectId, hide) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, hideStoryCards: !!hide } : p) };
    emit();
  },
  setHideDoneCards(projectId, hide) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, hideDoneCards: !!hide } : p) };
    emit();
  },
  setWeekStartsMonday(monday) {
    state = { ...state, weekStartsMonday: !!monday };
    emit();
  },
  setTlGroupBy(projectId, tlGroupBy) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, tlGroupBy } : p) };
    emit();
  },

  setFilters(projectId, filters) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, filters } : p) };
    emit();
  },
  setSearchText(projectId, text) {
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, searchText: text } : p) };
    emit();
  },
  setStageCollapsed(projectId, stageId, collapsed) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const stages = proj.stages.map(s => s.id === stageId ? { ...s, collapsed } : s);
    actions.updateProject(projectId, { stages });
  },

  addTask(projectId, stageId, title) {
    const sib = getProjectTasks(projectId).filter(t => t.stageId === stageId);
    const task = {
      id: uid('t_'), projectId, stageId, title: title || 'Untitled',
      notes: '', values: {}, subtasks: [], depsIn: [], depsOut: [], comments: [], order: sib.length,
      isEpic: false, epicId: null, epicColor: null, epicChildrenVisible: true, epicPos: null,
      isStory: false, storyId: null, storyColor: null, storyChildrenVisible: true, storyPos: null,
      done: false,
    };
    state = { ...state, tasks: [...state.tasks, task] };
    emit();
    return task.id;
  },
  updateTask(id, patch) {
    state = { ...state, tasks: state.tasks.map(t => t.id === id ? { ...t, ...patch } : t) };
    emit();
  },
  setTaskValue(id, fieldId, value) {
    const task = getTask(id); if (!task) return;
    const values = { ...task.values, [fieldId]: value };
    if (value === '' || value === null || value === undefined) delete values[fieldId];
    actions.updateTask(id, { values });
    // Builtin-field containment: an Epic's (or Story's) "due" date is a hard
    // ceiling for everything nested under it — descendants with no due date
    // inherit it, and descendants whose due date now falls after it get
    // clamped back to match. "start" and "priority" are simpler: they only
    // backfill descendants that haven't set their own value explicitly.
    // Recurses through Stories so a Story's own children stay in sync too.
    if (value && (task.isEpic || task.isStory)) {
      if (fieldId === 'due') cascadeBuiltinField(id, 'due', value, true);
      else if (fieldId === 'start' || fieldId === 'priority') cascadeBuiltinField(id, fieldId, value, false);
    }
  },
  deleteTask(id) {
    // also clean up dep references + epic links on other tasks
    state = {
      ...state,
      tasks: state.tasks
        .filter(t => t.id !== id)
        .map(t => ({
          ...t,
          depsIn:  (t.depsIn  || []).filter(x => x !== id),
          depsOut: (t.depsOut || []).filter(x => x !== id),
          epicId: t.epicId === id ? null : t.epicId,
          storyId: t.storyId === id ? null : t.storyId,
        })),
    };
    // activeTaskId is per-browser UI state (see uiState above) — only clear
    // THIS browser's open panel if it was pointed at the deleted task; other
    // logged-in browsers keep whatever they had open.
    if (uiState.activeTaskId === id) { uiState = { ...uiState, activeTaskId: null }; saveUiState(); }
    emit();
  },

  // ── Epics ──────────────────────────────────────────────────────────────
  // Converting a task to an Epic: epics organize work through linked tickets,
  // not their own subtasks. Any existing subtasks are promoted to standalone
  // tickets nested under the new epic (rather than silently discarded).
  setEpicFlag(id, isEpic) {
    const task = getTask(id); if (!task) return;
    if (isEpic) {
      const existingEpics = state.tasks.filter(t => t.projectId === task.projectId && t.isEpic && t.id !== id).length;
      const color = task.epicColor || PALETTE_COLORS[existingEpics % PALETTE_COLORS.length];
      const epicOrder = task.epicOrder ?? existingEpics;
      const sib = getProjectTasks(task.projectId).filter(t => t.stageId === task.stageId);
      const promoted = (task.subtasks || []).map((st, i) => ({
        id: uid('t_'), projectId: task.projectId, stageId: task.stageId, title: st.text || 'Untitled',
        notes: '', values: {}, subtasks: [], depsIn: [], depsOut: [], comments: [],
        order: sib.length + i,
        isEpic: false, epicId: id, epicColor: null, epicChildrenVisible: true, epicPos: null,
        done: !!st.done,
      }));
      state = {
        ...state,
        tasks: [
          ...state.tasks.map(t => {
            if (t.id === id) return { ...t, isEpic: true, epicId: null, epicColor: color, epicOrder, subtasks: [], isStory: false, storyId: null };
            // This task used to be a Story — carry over any children that were
            // already linked to it via storyId so they don't get orphaned.
            if (t.storyId === id) return { ...t, storyId: null, epicId: id };
            return t;
          }),
          ...promoted,
        ],
      };
      emit();
    } else {
      state = {
        ...state,
        tasks: state.tasks.map(t => {
          if (t.id === id) return { ...t, isEpic: false };
          if (t.epicId === id) return { ...t, epicId: null };
          return t;
        }),
      };
      emit();
    }
  },
  setEpicColor(id, color) {
    actions.updateTask(id, { epicColor: color });
  },
  reorderEpics(projectId, fromIdx, toIdx) {
    const epics = getEpics(projectId);
    const ids = epics.map(e => e.id);
    const [moved] = ids.splice(fromIdx, 1);
    ids.splice(toIdx, 0, moved);
    state = { ...state, tasks: state.tasks.map(t => {
      const idx = ids.indexOf(t.id);
      return idx === -1 ? t : { ...t, epicOrder: idx };
    }) };
    emit();
  },
  reorderStories(projectId, fromIdx, toIdx) {
    const stories = getStories(projectId);
    const ids = stories.map(st => st.id);
    const [moved] = ids.splice(fromIdx, 1);
    ids.splice(toIdx, 0, moved);
    state = { ...state, tasks: state.tasks.map(t => {
      const idx = ids.indexOf(t.id);
      return idx === -1 ? t : { ...t, storyOrder: idx };
    }) };
    emit();
  },
  setTaskEpic(id, epicId) {
    const task = getTask(id); if (!task) return;
    const patch = { epicId: epicId || null };
    if (epicId && !task.isStory) patch.storyId = null; // moving directly under an epic leaves any story
    actions.updateTask(id, patch);
  },
  setEpicChildrenVisible(id, visible) {
    actions.updateTask(id, { epicChildrenVisible: !!visible });
  },
  setEpicPos(id, x, y) {
    actions.updateTask(id, { epicPos: { x, y } });
  },

  // ── Stories ─────────────────────────────────────────────────────────────
  // A Story sits between Epic and Task: it can only hold Tasks (never other
  // Stories), and — like Epics — organizes them through links rather than its
  // own subtasks, so existing subtasks get promoted to linked tasks here too.
  setStoryFlag(id, isStory) {
    const task = getTask(id); if (!task) return;
    if (isStory) {
      const existingStories = state.tasks.filter(t => t.projectId === task.projectId && t.isStory && t.id !== id).length;
      const color = task.storyColor || PALETTE_COLORS[existingStories % PALETTE_COLORS.length];
      const storyOrder = task.storyOrder ?? existingStories;
      const sib = getProjectTasks(task.projectId).filter(t => t.stageId === task.stageId);
      const promoted = (task.subtasks || []).map((st, i) => ({
        id: uid('t_'), projectId: task.projectId, stageId: task.stageId, title: st.text || 'Untitled',
        notes: '', values: {}, subtasks: [], depsIn: [], depsOut: [], comments: [],
        order: sib.length + i,
        isEpic: false, epicId: null, epicColor: null, epicChildrenVisible: true, epicPos: null,
        isStory: false, storyId: id, storyColor: null, storyChildrenVisible: true, storyPos: null,
        done: !!st.done,
      }));
      state = {
        ...state,
        tasks: [
          ...state.tasks.map(t => {
            if (t.id === id) return { ...t, isStory: true, storyId: null, subtasks: [], isEpic: false, storyColor: color, storyOrder };
            // This task used to be an Epic — carry over any children that were
            // already linked to it via epicId so they don't get orphaned.
            if (t.epicId === id) return { ...t, epicId: null, storyId: id };
            return t;
          }),
          ...promoted,
        ],
      };
      emit();
    } else {
      state = {
        ...state,
        tasks: state.tasks.map(t => {
          if (t.id === id) return { ...t, isStory: false };
          if (t.storyId === id) return { ...t, storyId: null };
          return t;
        }),
      };
      emit();
    }
  },
  setStoryColor(id, color) {
    actions.updateTask(id, { storyColor: color });
  },
  setTaskStory(id, storyId) {
    const task = getTask(id); if (!task) return;
    const patch = { storyId: storyId || null };
    if (storyId) patch.epicId = null; // a task under a story reaches its epic through the story
    actions.updateTask(id, patch);
  },
  setStoryChildrenVisible(id, visible) {
    actions.updateTask(id, { storyChildrenVisible: !!visible });
  },
  // Bulk collapse/expand — sets every Epic's and Story's own child-visibility
  // flag at once (the same flag each card's own 👁/🙈 toggle controls),
  // rather than introducing a separate board-wide setting to track.
  setAllEpicStoryChildrenVisible(projectId, visible) {
    const epicIds = new Set(getEpics(projectId).map(e => e.id));
    const storyIds = new Set(getStories(projectId).map(st => st.id));
    if (!epicIds.size && !storyIds.size) return;
    state = {
      ...state,
      tasks: state.tasks.map(t => {
        if (epicIds.has(t.id)) return { ...t, epicChildrenVisible: !!visible };
        if (storyIds.has(t.id)) return { ...t, storyChildrenVisible: !!visible };
        return t;
      }),
    };
    emit();
  },
  setStoryPos(id, x, y) {
    actions.updateTask(id, { storyPos: { x, y } });
  },
  setTaskDone(id, done) {
    actions.updateTask(id, { done: !!done });
  },
  moveTask(taskId, toStageId, toOrder) {
    const task = getTask(taskId); if (!task) return;
    const fromStageId = task.stageId;
    const sameStage = fromStageId === toStageId;
    let tasks = state.tasks.map(t => t === task ? { ...t, stageId: toStageId } : t);
    const target = tasks.filter(t => t.projectId === task.projectId && t.stageId === toStageId && t.id !== taskId).sort((a,b) => a.order - b.order);
    // spec is either a neighbor reference — { beforeId } / { afterId }, resolved
    // against this (full, unfiltered) per-stage list so hidden/collapsed/
    // filtered-out tasks keep their relative position untouched — or, for
    // back-compat, a plain numeric index into this same list (used by the
    // "Move to stage" menu, which always appends: target.length).
    let insertAt;
    if (toOrder && typeof toOrder === 'object') {
      if (toOrder.beforeId != null) {
        const idx = target.findIndex(t => t.id === toOrder.beforeId);
        insertAt = idx !== -1 ? idx : target.length;
      } else if (toOrder.afterId != null) {
        const idx = target.findIndex(t => t.id === toOrder.afterId);
        insertAt = idx !== -1 ? idx + 1 : target.length;
      } else {
        insertAt = target.length;
      }
    } else {
      insertAt = Math.max(0, Math.min(toOrder, target.length));
    }
    target.splice(insertAt, 0, tasks.find(t => t.id === taskId));
    target.forEach((t, i) => { t.order = i; });
    if (!sameStage) {
      const src = tasks.filter(t => t.projectId === task.projectId && t.stageId === fromStageId).sort((a,b) => a.order - b.order);
      src.forEach((t, i) => { t.order = i; });
    }
    state = { ...state, tasks };
    emit();
  },

  // Subtasks
  addSubtask(taskId, text) {
    const task = getTask(taskId); if (!task) return;
    const subtasks = [...task.subtasks, { id: uid('st_'), text: text || 'New subtask', done: false }];
    actions.updateTask(taskId, { subtasks });
  },
  updateSubtask(taskId, stId, patch) {
    const task = getTask(taskId); if (!task) return;
    const subtasks = task.subtasks.map(st => st.id === stId ? { ...st, ...patch } : st);
    actions.updateTask(taskId, { subtasks });
  },
  deleteSubtask(taskId, stId) {
    const task = getTask(taskId); if (!task) return;
    actions.updateTask(taskId, { subtasks: task.subtasks.filter(st => st.id !== stId) });
  },

  // Comments
  addComment(taskId, text) {
    const task = getTask(taskId); if (!task) return;
    const comment = { id: uid('c_'), text: text.trim(), createdAt: new Date().toISOString() };
    actions.updateTask(taskId, { comments: [...(task.comments || []), comment] });
  },
  deleteComment(taskId, commentId) {
    const task = getTask(taskId); if (!task) return;
    actions.updateTask(taskId, { comments: (task.comments || []).filter(c => c.id !== commentId) });
  },

  // Dependencies — bidirectional
  addDep(taskId, otherId, direction /* 'in' | 'out' */) {
    if (taskId === otherId) return;
    const a = getTask(taskId), b = getTask(otherId);
    if (!a || !b) return;
    const tasks = state.tasks.map(t => {
      if (t.id === taskId) {
        const list = direction === 'in' ? 'depsIn' : 'depsOut';
        if ((t[list] || []).includes(otherId)) return t;
        return { ...t, [list]: [...(t[list] || []), otherId] };
      }
      if (t.id === otherId) {
        const list = direction === 'in' ? 'depsOut' : 'depsIn';
        if ((t[list] || []).includes(taskId)) return t;
        return { ...t, [list]: [...(t[list] || []), taskId] };
      }
      return t;
    });
    state = { ...state, tasks };
    emit();
  },
  removeDep(taskId, otherId, direction) {
    const tasks = state.tasks.map(t => {
      if (t.id === taskId) {
        const list = direction === 'in' ? 'depsIn' : 'depsOut';
        return { ...t, [list]: (t[list] || []).filter(x => x !== otherId) };
      }
      if (t.id === otherId) {
        const list = direction === 'in' ? 'depsOut' : 'depsIn';
        return { ...t, [list]: (t[list] || []).filter(x => x !== taskId) };
      }
      return t;
    });
    state = { ...state, tasks };
    emit();
  },

  // Projects
  addProject(name) {
    const id = uid('p_');
    const project = {
      id, name, color: '#7a9bc4',
      priorityColors: { ...DEFAULT_PRIORITY_COLORS },
      stages: [
        { id: uid('s_'), name: 'Backlog',     colorId: 'none' },
        { id: uid('s_'), name: 'In progress', colorId: 'blue' },
        { id: uid('s_'), name: 'Done',        colorId: 'green' },
      ],
      fields: [...makeBuiltinFields()],
      groupBy: 'none',
      tlGroupBy: 'none',
      filters: [],
    };
    state = { ...state, projects: [...state.projects, project] };
    // Navigate THIS browser to the new project — doesn't affect anyone else
    // since activeProjectId is per-browser UI state.
    uiState = { ...uiState, activeProjectId: id };
    saveUiState();
    emit();
  },
  updateProject(id, patch) {
    state = { ...state, projects: state.projects.map(p => p.id === id ? { ...p, ...patch } : p) };
    emit();
  },
  setPriorityColor(projectId, level, color) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const priorityColors = { ...(proj.priorityColors || DEFAULT_PRIORITY_COLORS), [level]: color };
    actions.updateProject(projectId, { priorityColors });
  },
  deleteProject(id) {
    if (state.projects.length <= 1) return;
    const remaining = state.projects.filter(p => p.id !== id);
    state = {
      ...state,
      projects: remaining,
      tasks: state.tasks.filter(t => t.projectId !== id),
    };
    // activeProjectId is per-browser UI state — only redirect THIS browser
    // away from the deleted project if it was looking at it.
    if (uiState.activeProjectId === id) { uiState = { ...uiState, activeProjectId: remaining[0].id }; saveUiState(); }
    emit();
  },

  // Stages
  addStage(projectId, name) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const stages = [...proj.stages, { id: uid('s_'), name, colorId: 'none' }];
    actions.updateProject(projectId, { stages });
  },
  updateStage(projectId, stageId, patch) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const stages = proj.stages.map(s => s.id === stageId ? { ...s, ...patch } : s);
    actions.updateProject(projectId, { stages });
  },
  deleteStage(projectId, stageId) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    if (proj.stages.length <= 1) return;
    const stages = proj.stages.filter(s => s.id !== stageId);
    const fallback = stages[0].id;
    const tasks = state.tasks.map(t => (t.projectId === projectId && t.stageId === stageId) ? { ...t, stageId: fallback } : t);
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, stages } : p), tasks };
    emit();
  },
  reorderStages(projectId, fromIdx, toIdx) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const stages = [...proj.stages];
    const [moved] = stages.splice(fromIdx, 1);
    stages.splice(toIdx, 0, moved);
    actions.updateProject(projectId, { stages });
  },

  // Custom fields
  addField(projectId, field) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const f = { id: uid('f_'), name: 'Untitled', type: 'text', options: [], showOnCard: true, color: '#9a948a', ...field };
    actions.updateProject(projectId, { fields: [...proj.fields, f] });
  },
  updateField(projectId, fieldId, patch) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const fields = proj.fields.map(f => f.id === fieldId ? { ...f, ...patch } : f);
    actions.updateProject(projectId, { fields });
  },
  deleteField(projectId, fieldId) {
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const fields = proj.fields.filter(f => f.id !== fieldId);
    const tasks = state.tasks.map(t => {
      if (t.projectId !== projectId || !(fieldId in t.values)) return t;
      const values = { ...t.values }; delete values[fieldId];
      return { ...t, values };
    });
    state = { ...state, projects: state.projects.map(p => p.id === projectId ? { ...p, fields } : p), tasks };
    emit();
  },
  // Adds a new option to a select/multi_select field's shared option list — used
  // when a user types a brand-new option from a card instead of project settings,
  // so it becomes a real, removable, project-wide option (not just a value stuck
  // on that one task).
  addFieldOption(projectId, fieldId, option) {
    const opt = (option || '').trim(); if (!opt) return;
    const proj = state.projects.find(p => p.id === projectId); if (!proj) return;
    const field = proj.fields.find(f => f.id === fieldId); if (!field) return;
    const existing = field.options || [];
    if (existing.includes(opt)) return;
    const fields = proj.fields.map(f => f.id === fieldId ? { ...f, options: [...existing, opt] } : f);
    actions.updateProject(projectId, { fields });
  },

  resetData() { state = seedData(); emit(); },
  importData(data) { state = migrate(data); emit(); },

  // ── Workspaces (server backend only) ───────────────────────────
  async switchWorkspace(name) {
    window.serverAPI.setActiveWorkspace(name);
    await initServerWorkspace(name);
    listeners.forEach(fn => fn(state));
  },
  async createWorkspace(name) {
    await window.serverAPI.createWorkspace(name, seedData());
    await actions.switchWorkspace(name);
  },
};

function useStore() {
  const [s, set] = React.useState(snapshot);
  React.useEffect(() => subscribe(set), []);
  return s;
}

// Helpers
function prioClass(p) {
  if (p === 'High') return 'prio-high';
  if (p === 'Med') return 'prio-med';
  return 'prio-low';
}
function prioChipKind(p) {
  if (p === 'High') return 'warm';
  if (p === 'Med') return 'yellow';
  return '';
}
function priorityColor(project, level) {
  return (project?.priorityColors || DEFAULT_PRIORITY_COLORS)[level] || DEFAULT_PRIORITY_COLORS[level] || '#9a948a';
}
// Color for calendar/timeline chips: the task's epic color if it belongs to
// an epic (directly, or is the epic itself), otherwise neutral gray.
function taskColor(task) {
  if (!task) return '#9a948a';
  if (task.epicId) {
    const epic = getTask(task.epicId);
    if (epic && epic.epicColor) return epic.epicColor;
  }
  if (task.isEpic && task.epicColor) return task.epicColor;
  return '#9a948a';
}
function fmtDuration(min) {
  if (!min) return '0m';
  const h = Math.floor(min / 60); const m = min % 60;
  return h ? `${h}h${m ? ' ' + m + 'm' : ''}` : `${m}m`;
}
function parseDuration(s) {
  if (!s) return 0;
  const text = String(s).trim().toLowerCase();
  let total = 0;
  const hMatch = text.match(/([\d.]+)\s*h/);
  const mMatch = text.match(/([\d.]+)\s*m/);
  if (hMatch) total += parseFloat(hMatch[1]) * 60;
  if (mMatch) total += parseFloat(mMatch[1]);
  if (!hMatch && !mMatch) {
    const n = parseFloat(text);
    if (!isNaN(n)) total = n * 60;
  }
  return Math.round(total);
}
function fmtDate(d) {
  if (!d) return '';
  const dt = typeof d === 'string' ? new Date(d) : d;
  if (isNaN(dt)) return d;
  const today = new Date(); today.setHours(0,0,0,0);
  const target = new Date(dt); target.setHours(0,0,0,0);
  const diff = Math.round((target - today) / 86400000);
  if (diff === 0) return 'Today';
  if (diff === 1) return 'Tomorrow';
  if (diff === -1) return 'Yesterday';
  if (diff > 0 && diff < 7) return target.toLocaleDateString(undefined, { weekday: 'short' });
  return target.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

// Filter helpers
function fieldOf(project, fieldId) {
  if (fieldId === '__epic__') {
    const epics = getEpics(project.id);
    return {
      id: '__epic__', name: 'Epic', type: 'select', builtin: true, color: '#9a948a',
      options: epics.map(e => e.id),
      optionLabels: Object.fromEntries(epics.map(e => [e.id, e.title])),
    };
  }
  if (fieldId === '__story__') {
    const stories = getStories(project.id);
    return {
      id: '__story__', name: 'Story', type: 'select', builtin: true, color: '#7a9bc4',
      options: stories.map(st => st.id),
      optionLabels: Object.fromEntries(stories.map(st => [st.id, st.title])),
    };
  }
  return project.fields.find(f => f.id === fieldId);
}
function applyFilters(project, tasks, opts) {
  const filters = (project.filters || []).filter(f => f.fieldId && f.op);
  const search = (project.searchText || '').trim().toLowerCase();
  let out = tasks;
  if (search) {
    out = out.filter(t =>
      (t.title || '').toLowerCase().includes(search) ||
      (t.notes || '').toLowerCase().includes(search)
    );
  }
  if (filters.length) out = out.filter(t => filters.every(f => matchFilter(t, f, project)));
  // "Hide epics" / "Hide stories" — keeps the Epic/Story summary cards out of
  // Board/List/Calendar/Timeline so they don't clutter regular task views.
  // They're still fully visible on the dedicated Epics/Stories tabs (those
  // screens read tasks directly, not through applyFilters), and grouping a
  // view by Epic/Story already excludes these summary cards from lanes on
  // its own, so the toggle has nothing left to do there anyway.
  if (project.hideEpicCards) out = out.filter(t => !t.isEpic);
  if (project.hideStoryCards) out = out.filter(t => !t.isStory);
  // "Hide done" — keeps completed cards out of the view without touching
  // their data; unrelated to the epic/story collapse logic below.
  if (project.hideDoneCards) out = out.filter(t => !t.done);
  // When a screen is itself grouped by Epic or Story, every lane already IS
  // an epic/story — hiding its children would just leave empty lanes, so the
  // per-card "hide linked cards" toggle is ignored in that view.
  if (opts && opts.ignoreCollapse) return out;
  // When the user has explicitly filtered by a specific Epic or Story, they
  // clearly want to see that epic's/story's children — so exempt it from the
  // collapse logic below even if its own card is individually collapsed.
  const filteredEpicIds = new Set(filters.filter(f => f.fieldId === '__epic__' && f.op === 'eq' && f.value).map(f => f.value));
  const filteredStoryIds = new Set(filters.filter(f => f.fieldId === '__story__' && f.op === 'eq' && f.value).map(f => f.value));
  // When epics/stories themselves are hidden via the toggle, their linked
  // tasks should never stay collapsed too — there'd be no way to un-collapse
  // them without un-hiding the summary card first. Force those children back
  // to visible by leaving them out of the "collapsed" sets below.
  const hidingEpicIds = project.hideEpicCards ? new Set() : new Set(state.tasks.filter(t => t.isEpic && t.epicChildrenVisible === false && !filteredEpicIds.has(t.id)).map(t => t.id));
  const hidingStoryIds = project.hideStoryCards ? new Set() : new Set(state.tasks.filter(t => t.isStory && t.storyChildrenVisible === false && !filteredStoryIds.has(t.id)).map(t => t.id));
  if (hidingEpicIds.size || hidingStoryIds.size) {
    out = out.filter(t => {
      if (t.epicId && hidingEpicIds.has(t.epicId)) return false;
      if (t.storyId) {
        if (hidingStoryIds.has(t.storyId)) return false;
        const story = state.tasks.find(s => s.id === t.storyId);
        if (story && story.epicId && hidingEpicIds.has(story.epicId)) return false;
      }
      return true;
    });
  }
  return out;
}
function matchFilter(task, f, project) {
  // Epic/Story are pseudo-fields — not part of task.values, so their value
  // comes from the task's own epicId/storyId link instead. A task linked to
  // a Story that itself belongs to an Epic (task -> story -> epic) counts as
  // an epic child too, not just tasks linked directly to the epic.
  const v = f.fieldId === '__epic__'
    ? (task.epicId || (task.storyId && getTask(task.storyId)?.epicId) || '')
    : f.fieldId === '__story__' ? (task.storyId || '')
    : task.values[f.fieldId];
  const field = fieldOf(project, f.fieldId);
  switch (f.op) {
    case 'is_empty':     return v === undefined || v === '' || v === null || (Array.isArray(v) && v.length === 0);
    case 'is_not_empty': return !(v === undefined || v === '' || v === null || (Array.isArray(v) && v.length === 0));
    case 'eq':           return Array.isArray(v) ? v.includes(f.value) : String(v ?? '') === String(f.value ?? '');
    case 'neq':          return Array.isArray(v) ? !v.includes(f.value) : String(v ?? '') !== String(f.value ?? '');
    case 'contains':     return String(v ?? '').toLowerCase().includes(String(f.value ?? '').toLowerCase());
    case 'before':       return v && new Date(v) < new Date(f.value);
    case 'after':        return v && new Date(v) > new Date(f.value);
    default:             return true;
  }
}

// Builtin-field containment: recursively clamps/backfills descendant values
// for an Epic/Story's "due", "start", or "priority" fields. "due" is a hard
// ceiling (clamp=true): descendants past it get pulled back. "start" and
// "priority" only fill in blanks (clamp=false): a descendant that already
// set its own value is left alone.
function cascadeBuiltinField(parentId, fieldId, parentValue, clamp) {
  const parent = getTask(parentId); if (!parent) return;
  const children = parent.isEpic
    ? state.tasks.filter(t => t.epicId === parentId)
    : parent.isStory
      ? state.tasks.filter(t => t.storyId === parentId)
      : [];
  let changed = false;
  children.forEach(child => {
    const childValue = child.values[fieldId];
    const shouldSet = !childValue || (clamp && childValue > parentValue);
    if (shouldSet) {
      state = { ...state, tasks: state.tasks.map(t => t.id === child.id ? { ...t, values: { ...t.values, [fieldId]: parentValue } } : t) };
      changed = true;
      if (child.isStory) cascadeBuiltinField(child.id, fieldId, parentValue, clamp);
    }
  });
  if (changed) emit();
}

// Epic helpers
function getEpics(projectId) {
  return state.tasks
    .filter(t => t.projectId === projectId && t.isEpic)
    .sort((a, b) => (a.epicOrder ?? 0) - (b.epicOrder ?? 0));
}
function getEpicChildren(epicId) {
  return state.tasks.filter(t => t.epicId === epicId);
}
function getEpicProgress(project, epicId) {
  // Each direct child counts as ONE unit — a Story counts as done when it's
  // internally complete (or has no tasks but is itself marked done), so an
  // epic's ratio reads as "stories + direct tickets", not raw leaf tasks
  // (tickets nested under a Story are represented by that Story, not counted
  // again on their own).
  const directChildren = state.tasks.filter(t => t.epicId === epicId);
  if (directChildren.length === 0) return { done: 0, total: 0 };
  const done = directChildren.filter(t => {
    if (t.isStory) {
      const sp = getStoryProgress(t.id);
      return sp.total > 0 ? sp.done === sp.total : !!t.done;
    }
    return !!t.done;
  }).length;
  return { done, total: directChildren.length };
}

// Story helpers
function getStories(projectId) {
  return state.tasks
    .filter(t => t.projectId === projectId && t.isStory)
    .sort((a, b) => (a.storyOrder ?? 0) - (b.storyOrder ?? 0));
}
function getStoryChildren(storyId) {
  return state.tasks.filter(t => !t.isEpic && !t.isStory && t.storyId === storyId);
}
function getStoryProgress(storyId) {
  const children = getStoryChildren(storyId);
  if (children.length === 0) return { done: 0, total: 0 };
  const done = children.filter(t => t.done).length;
  return { done, total: children.length };
}

// Returns { id, type: 'epic'|'story', label, title, color } for a task/story's
// direct parent, or null if it has none — used for the little "jump to parent"
// button on cards.
function getParentInfo(task) {
  if (!task) return null;
  if (task.storyId) {
    const story = getTask(task.storyId);
    if (story) return { id: story.id, type: 'story', label: 'Story', title: story.title, color: story.storyColor };
  }
  if (task.epicId) {
    const epic = getTask(task.epicId);
    if (epic) return { id: epic.id, type: 'epic', label: 'Epic', title: epic.title, color: epic.epicColor };
  }
  return null;
}

Object.assign(window, {
  store: { get state() { return snapshot(); }, subscribe },
  actions, useStore,
  prioClass, prioChipKind, priorityColor, taskColor, fmtDuration, parseDuration, fmtDate,
  FIELD_TYPES, FIELD_TYPE_CATALOG, COLOR_PALETTE, PALETTE_COLORS, DEFAULT_PRIORITY_COLORS,
  getActiveProject, getProjectTasks, getTask,
  getEpics, getEpicChildren, getEpicProgress,
  getStories, getStoryChildren, getStoryProgress, getParentInfo,
  fieldOf, applyFilters,
  seedData,
  initLocalOrElectron, initServerWorkspace,
  getBackend: () => activeBackend,
  getActiveWorkspaceName: () => activeWorkspaceName,
});
