pgkiln 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/db/migrations/095_rest_catalogs.sql +59 -0
  2. package/db/migrations/096_developer_email.sql +15 -0
  3. package/docs/apex-feature-parity.md +12 -9
  4. package/docs/guide/03-builder.md +37 -1
  5. package/docs/guide/04-pages-and-regions.md +37 -4
  6. package/docs/guide/12-development.md +18 -1
  7. package/docs/guide/19-rest-data-sources.md +39 -1
  8. package/examples/hr/hr_59_gantt_editing.sql +21 -0
  9. package/examples/hr/hr_60_tree_charts.sql +46 -0
  10. package/package.json +1 -1
  11. package/public/app.css +44 -0
  12. package/public/app.js +126 -0
  13. package/public/builder.css +1 -1
  14. package/public/sw.js +5 -2
  15. package/src/app.ts +6 -0
  16. package/src/builder/find-workspace.ts +120 -0
  17. package/src/builder/home.ts +2 -0
  18. package/src/builder/mail.ts +39 -0
  19. package/src/builder/region-settings.ts +19 -1
  20. package/src/builder/rest-catalogs.ts +424 -0
  21. package/src/builder/routes.ts +32 -6
  22. package/src/builder/shared.ts +2 -0
  23. package/src/builder/workspace-transfer.ts +136 -0
  24. package/src/i18n/ar.ts +14 -0
  25. package/src/i18n/cs.ts +14 -0
  26. package/src/i18n/da.ts +14 -0
  27. package/src/i18n/de.ts +14 -0
  28. package/src/i18n/el.ts +14 -0
  29. package/src/i18n/es.ts +14 -0
  30. package/src/i18n/fi.ts +14 -0
  31. package/src/i18n/fr.ts +14 -0
  32. package/src/i18n/he.ts +14 -0
  33. package/src/i18n/it.ts +14 -0
  34. package/src/i18n/ja.ts +14 -0
  35. package/src/i18n/ko.ts +14 -0
  36. package/src/i18n/nb.ts +14 -0
  37. package/src/i18n/pl.ts +14 -0
  38. package/src/i18n/pt.ts +14 -0
  39. package/src/i18n/ru.ts +14 -0
  40. package/src/i18n/sv.ts +14 -0
  41. package/src/i18n/tr.ts +14 -0
  42. package/src/i18n/uk.ts +14 -0
  43. package/src/i18n/zh.ts +14 -0
  44. package/src/i18n.ts +28 -0
  45. package/src/restcatalog.ts +605 -0
  46. package/src/runtime/charts.ts +296 -12
  47. package/src/runtime/data-reporter.ts +48 -16
  48. package/src/runtime/gantt-move.ts +63 -0
  49. package/src/runtime/regions.ts +3 -0
  50. package/src/runtime/routes.ts +39 -0
  51. package/src/websources.ts +11 -3
  52. package/src/workspace-export.ts +240 -0
@@ -30,8 +30,8 @@ import { cell } from './report.ts';
30
30
  // row, or one value column with a row per observation (the label repeats; the
31
31
  // statistics are computed per label); a stock chart open, high, low and close.
32
32
 
33
- export type ChartKind = 'bar' | 'column' | 'stacked' | 'line' | 'area' | 'combo' | 'scatter' | 'donut' | 'pie' | 'bubble' | 'gauge' | 'funnel' | 'radar' | 'gantt' | 'pyramid' | 'polar' | 'range' | 'boxplot' | 'stock';
34
- export const CHART_KINDS: ChartKind[] = ['bar', 'column', 'stacked', 'line', 'area', 'combo', 'scatter', 'donut', 'pie', 'bubble', 'gauge', 'funnel', 'radar', 'gantt', 'pyramid', 'polar', 'range', 'boxplot', 'stock'];
33
+ export type ChartKind = 'bar' | 'column' | 'stacked' | 'line' | 'area' | 'combo' | 'scatter' | 'donut' | 'pie' | 'bubble' | 'gauge' | 'funnel' | 'radar' | 'gantt' | 'pyramid' | 'polar' | 'range' | 'boxplot' | 'stock' | 'treemap' | 'sunburst' | 'timeline';
34
+ export const CHART_KINDS: ChartKind[] = ['bar', 'column', 'stacked', 'line', 'area', 'combo', 'scatter', 'donut', 'pie', 'bubble', 'gauge', 'funnel', 'radar', 'gantt', 'pyramid', 'polar', 'range', 'boxplot', 'stock', 'treemap', 'sunburst', 'timeline'];
35
35
 
36
36
  /** A gauge's scale and thresholds (config.gauge): warning above critical means low values are bad. */
37
37
  export interface GaugeConfig {
@@ -51,6 +51,8 @@ export interface ChartOptions {
51
51
  format?: (v: number) => string;
52
52
  /** a Gantt chart's "today" line (milliseconds, wall clock as UTC); default now */
53
53
  now?: number;
54
+ /** (sprint 69) an editable Gantt chart: its region, and the step of a drag (default: days when every date is midnight) */
55
+ edit?: { region: number; snap?: 'day' | 'hour' };
54
56
  }
55
57
  /** The interactive report's chart view has one series with text labels: no stacked, combo or scatter. */
56
58
  export const REPORT_CHART_KINDS: ChartKind[] = ['bar', 'column', 'line', 'area', 'donut', 'pie'];
@@ -62,15 +64,15 @@ interface Series {
62
64
 
63
65
  let link: ChartOptions['link'];
64
66
  /** A mark: an <a> when the chart drills down (row i, series si), else `tag` (focusable when asked). */
65
- function mark(tag: 'div' | 'span', i: number, si: number | null, cls: string, tip: string | null, inner: unknown, focusable = true, namedByContent = false): Raw {
67
+ function mark(tag: 'div' | 'span', i: number, si: number | null, cls: string, tip: string | null, inner: unknown, focusable = true, namedByContent = false, attrs: Raw | '' = ''): Raw {
66
68
  const href = link?.(i, si) ?? null;
67
69
  // a drill link with visible text is named by that text; elsewhere the tip is the name (role img: a labelled graphic)
68
70
  const tipAttr = tip === null ? '' : href && namedByContent ? html` data-tip="${tip}"` : html` data-tip="${tip}" aria-label="${tip}"`;
69
- if (href) return html`<a class="${cls} drill" ${href}${tipAttr}${focusable ? '' : raw(' tabindex="-1"')}>${inner}</a>`;
71
+ if (href) return html`<a class="${cls} drill" ${href}${tipAttr}${attrs}${focusable ? '' : raw(' tabindex="-1"')}>${inner}</a>`;
70
72
  const role = tip === null ? '' : raw(' role="img"');
71
73
  return tag === 'div'
72
- ? html`<div class="${cls}"${role}${tipAttr}${focusable ? raw(' tabindex="0"') : ''}>${inner}</div>`
73
- : html`<span class="${cls}"${role}${tipAttr}${focusable ? raw(' tabindex="0"') : ''}>${inner}</span>`;
74
+ ? html`<div class="${cls}"${role}${tipAttr}${attrs}${focusable ? raw(' tabindex="0"') : ''}>${inner}</div>`
75
+ : html`<span class="${cls}"${role}${tipAttr}${attrs}${focusable ? raw(' tabindex="0"') : ''}>${inner}</span>`;
74
76
  }
75
77
 
76
78
  const MAX_SERIES = 8;
@@ -98,6 +100,10 @@ let texts = {
98
100
  noRange: 'A range chart needs a label column and two numeric columns: low and high.',
99
101
  noBoxplot: 'A box plot needs a label column and either one numeric column (a row per value) or five: minimum, first quartile, median, third quartile and maximum.',
100
102
  noStock: 'A stock chart needs a label column (the date) and four numeric columns: open, high, low and close.',
103
+ noTree: 'A treemap or sunburst needs a label column and a numeric column (optionally node_id and parent_id columns for a hierarchy).',
104
+ noTimeline: 'A timeline needs a label column and a start date (an end date and a series column are optional).',
105
+ total: 'Total',
106
+ ganttHint: 'Drag a task to move it, or its end to change its length. With the keyboard: the arrow keys move a task, Shift and the arrow keys change its end, Enter saves, Escape cancels.',
101
107
  min: 'Minimum',
102
108
  q1: 'First quartile',
103
109
  median: 'Median',
@@ -810,7 +816,7 @@ function dateFormat(opts: Intl.DateTimeFormatOptions) {
810
816
  }
811
817
  }
812
818
 
813
- function gantt(title: string, rows: unknown[][], fields: { name: string }[], now: number) {
819
+ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now: number, edit?: ChartOptions['edit']) {
814
820
  // label, start, end (empty or equal to the start: a milestone); optional columns by name:
815
821
  // progress (0-100), task_id and depends_on (ids of the tasks it waits for: "3" or "3,4")
816
822
  if (fields.length < 3) return html`<p class="empty">${texts.noGantt}</p>`;
@@ -862,16 +868,25 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
862
868
  (b) => html`<span class="gantt-arrow ${css.cls(`left:${pct(x(tasks[b].start))};top:${pct(((b + 0.5) / n) * 100)}`)}"></span>`,
863
869
  );
864
870
 
871
+ // (sprint 69) editable: a task with a task_id carries its id and dates (wall clock ms) for app.js; bars get an end handle
872
+ const editable = !!edit && cId >= 0;
873
+ const snap = edit?.snap ?? (tasks.every((t) => t.start % DAY === 0 && t.end % DAY === 0) ? 'day' : 'hour');
874
+ const editAttrs = (t: (typeof tasks)[number]) => {
875
+ const id = editable ? cell(rows[t.i][cId]) : '';
876
+ // (draggable="false": a drill-down link would start the browser's own drag of a link)
877
+ return id ? html` data-task="${id}" data-label="${t.label}" data-start="${String(t.start)}" data-end="${String(t.end)}" draggable="false"` : '';
878
+ };
865
879
  const lanes = tasks.map((t) => {
866
- if (t.end === t.start) return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-milestone s1 ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), '')}</div>`;
880
+ if (t.end === t.start) return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-milestone s1 ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), '', true, false, editAttrs(t))}</div>`;
867
881
  const geo = css.cls(`left:${pct(x(t.start))};width:${pct(x(t.end) - x(t.start))}`);
868
882
  const done = t.progress === null ? '' : html`<span class="gantt-progress ${css.cls(`width:${pct(t.progress)}`)}"></span>`;
869
- return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-bar s1${t.progress === null ? '' : ' partial'} ${geo}`, tipOf(t), done)}</div>`;
883
+ const handle = editAttrs(t) ? html`<span class="gantt-handle" aria-hidden="true"></span>` : '';
884
+ return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-bar s1${t.progress === null ? '' : ' partial'} ${geo}`, tipOf(t), html`${done}${handle}`, true, false, editAttrs(t))}</div>`;
870
885
  });
871
886
  const everyTick = Math.ceil(scale.ticks.length / 8);
872
887
  const today = now >= scale.lo && now <= scale.hi ? html`<span class="gantt-today ${css.cls(`left:${pct(x(now))}`)}" title="${texts.today}"></span>` : '';
873
888
  const head = (k: number) => fields[k]?.name ?? '';
874
- return html`<div class="gantt">
889
+ return html`<div class="gantt${editable ? ' gantt-editable' : ''}"${editable ? html` data-gantt="${String(edit!.region)}" data-snap="${snap}" data-lo="${String(scale.lo)}" data-hi="${String(scale.hi)}"` : ''}>
875
890
  <span class="gantt-corner">${head(0)}</span>
876
891
  <div class="gantt-axis" aria-hidden="true">${scale.ticks.slice(0, -1).map(
877
892
  // every other shown label is "odd": hidden on narrow screens (app.css)
@@ -885,6 +900,7 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
885
900
  ${lanes}
886
901
  </div>
887
902
  </div>
903
+ ${editable ? html`<p class="gantt-status" role="status" aria-live="polite"></p><p class="gantt-hint muted">${texts.ganttHint}</p>` : ''}
888
904
  <details class="chart-data"><summary>${texts.table}</summary>
889
905
  <div class="table-wrap"><table class="report">
890
906
  <caption class="sr-only">${title}</caption>
@@ -896,6 +912,269 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
896
912
  </table></div></details>`;
897
913
  }
898
914
 
915
+ // ---------------------------------------------------------------- treemap and sunburst (sprint 70)
916
+ // label, value; optional columns by name: node_id and parent_id (a node's parent; without
917
+ // node_id the label is the id). A node's size is its own value plus its children's.
918
+ interface TreeNode {
919
+ i: number;
920
+ id: string;
921
+ label: string;
922
+ own: number;
923
+ total: number;
924
+ parent: string | null;
925
+ children: TreeNode[];
926
+ depth: number;
927
+ }
928
+ const MAX_TREE_DEPTH = 4;
929
+
930
+ /** The nodes of a hierarchy chart, as a forest (cycles and unknown parents make roots). */
931
+ export function buildTree(rows: unknown[][], fields: { name: string }[]): TreeNode[] {
932
+ const col = (name: string) => fields.findIndex((f, i) => i > 1 && f.name.toLowerCase() === name);
933
+ const [cId, cParent] = [col('node_id'), col('parent_id')];
934
+ const nodes = rows.map((r, i): TreeNode => {
935
+ const v = Number(r[1]);
936
+ const label = cell(r[0]);
937
+ return { i, id: cId >= 0 ? cell(r[cId]) : label, label, own: Number.isFinite(v) && v > 0 ? v : 0, total: 0, parent: cParent >= 0 && r[cParent] !== null && r[cParent] !== '' ? cell(r[cParent]) : null, children: [], depth: 0 };
938
+ });
939
+ const byId = new Map<string, TreeNode>();
940
+ for (const n of nodes) if (!byId.has(n.id)) byId.set(n.id, n);
941
+ const roots: TreeNode[] = [];
942
+ // the parents given so far: a parent that is unknown, the node itself, or below it there makes a root
943
+ const given = new Map<TreeNode, TreeNode>();
944
+ for (const n of nodes) {
945
+ let p = n.parent !== null ? byId.get(n.parent) : undefined;
946
+ for (let a = p; a; a = given.get(a)) if (a === n) p = undefined;
947
+ if (p) {
948
+ given.set(n, p);
949
+ p.children.push(n);
950
+ } else roots.push(n);
951
+ }
952
+ const sum = (n: TreeNode, depth: number): number => {
953
+ n.depth = depth;
954
+ n.total = n.own + n.children.reduce((a, c) => a + sum(c, depth + 1), 0);
955
+ return n.total;
956
+ };
957
+ for (const r of roots) sum(r, 0);
958
+ return roots.filter((r) => r.total > 0);
959
+ }
960
+
961
+ interface Rect { x: number; y: number; w: number; h: number }
962
+ /** Squarified treemap layout (Bruls, Huizing, van Wijk): rectangles close to squares, largest first. */
963
+ export function squarify<T extends { total: number }>(items: T[], box: Rect): (T & Rect)[] {
964
+ const out: (T & Rect)[] = [];
965
+ const total = items.reduce((a, n) => a + n.total, 0);
966
+ if (!total || box.w <= 0 || box.h <= 0) return out;
967
+ const scale = (box.w * box.h) / total;
968
+ let rest = items.filter((n) => n.total > 0).sort((a, b) => b.total - a.total).map((n) => ({ n, area: n.total * scale }));
969
+ let { x, y, w, h } = box;
970
+ const worst = (row: { area: number }[], side: number) => {
971
+ const s = row.reduce((a, r) => a + r.area, 0);
972
+ const max = Math.max(...row.map((r) => r.area));
973
+ const min = Math.min(...row.map((r) => r.area));
974
+ return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
975
+ };
976
+ while (rest.length) {
977
+ const side = Math.min(w, h);
978
+ let k = 1;
979
+ while (k < rest.length && worst(rest.slice(0, k + 1), side) <= worst(rest.slice(0, k), side)) k++;
980
+ const row = rest.slice(0, k);
981
+ rest = rest.slice(k);
982
+ const s = row.reduce((a, r) => a + r.area, 0);
983
+ if (w >= h) {
984
+ const cw = rest.length ? s / h : w;
985
+ let yy = y;
986
+ for (const r of row) {
987
+ const rh = (r.area / s) * h;
988
+ out.push({ ...r.n, x, y: yy, w: cw, h: rh });
989
+ yy += rh;
990
+ }
991
+ x += cw;
992
+ w -= cw;
993
+ } else {
994
+ const rh = rest.length ? s / w : h;
995
+ let xx = x;
996
+ for (const r of row) {
997
+ const rw = (r.area / s) * w;
998
+ out.push({ ...r.n, x: xx, y, w: rw, h: rh });
999
+ xx += rw;
1000
+ }
1001
+ y += rh;
1002
+ h -= rh;
1003
+ }
1004
+ }
1005
+ return out;
1006
+ }
1007
+
1008
+ function hierarchy(kind: 'treemap' | 'sunburst', title: string, rows: unknown[][], fields: { name: string }[]) {
1009
+ if (fields.length < 2) return html`<p class="empty">${texts.noTree}</p>`;
1010
+ let roots = buildTree(rows, fields);
1011
+ if (!roots.length) return html`<p class="empty">${texts.noData}</p>`;
1012
+ // one root with children: its children are the top level (its name is the chart's centre)
1013
+ const top = roots.length === 1 && roots[0].children.length ? roots[0] : null;
1014
+ if (top) roots = top.children.filter((c) => c.total > 0);
1015
+ const total = roots.reduce((a, r) => a + r.total, 0);
1016
+ const share = (v: number) => `${((v / total) * 100).toFixed(1)}%`;
1017
+ const path = (n: TreeNode, chain: string[]) => [...chain, n.label].join(' › ');
1018
+ const tip = (n: TreeNode, chain: string[]) => `${path(n, chain)}: ${fmt.format(n.total)} (${share(n.total)})`;
1019
+ const colour = (k: number) => `s${(k % MAX_SERIES) + 1}`;
1020
+ const body = kind === 'treemap' ? treemap(roots, colour, tip) : sunburst(roots, colour, tip, top ? top.label : texts.total, total);
1021
+ // the data table: every node with its parent and size (the accessible alternative)
1022
+ const flat: { n: TreeNode; parent: string }[] = [];
1023
+ const walk = (n: TreeNode, parent: string) => {
1024
+ flat.push({ n, parent });
1025
+ for (const c of n.children) walk(c, n.label);
1026
+ };
1027
+ for (const r of top ? [top] : roots) walk(r, '');
1028
+ return html`${body}
1029
+ <details class="chart-data"><summary>${texts.table}</summary>
1030
+ <div class="table-wrap"><table class="report">
1031
+ <caption class="sr-only">${title}</caption>
1032
+ <thead><tr><th scope="col">${fields[0].name}</th><th scope="col">parent</th><th scope="col" class="num">${fields[1].name}</th><th scope="col" class="num">${texts.total}</th></tr></thead>
1033
+ <tbody>${flat.map(({ n, parent }) => {
1034
+ const l = link?.(n.i, null);
1035
+ return html`<tr><th scope="row">${l ? html`<a ${l}>${n.label}</a>` : n.label}</th><td>${parent}</td><td class="num">${fmt.format(n.own)}</td><td class="num">${fmt.format(n.total)}</td></tr>`;
1036
+ })}</tbody>
1037
+ </table></div></details>`;
1038
+ }
1039
+
1040
+ function treemap(roots: TreeNode[], colour: (k: number) => string, tip: (n: TreeNode, chain: string[]) => string) {
1041
+ // in units of a 16:10 area; percentages of it for the classes
1042
+ const W = 160;
1043
+ const H = 100;
1044
+ const geo = (b: Rect, outer: Rect) =>
1045
+ css.cls(`left:${pct(((b.x - outer.x) / outer.w) * 100)};top:${pct(((b.y - outer.y) / outer.h) * 100)};width:${pct((b.w / outer.w) * 100)};height:${pct((b.h / outer.h) * 100)}`);
1046
+ const area: Rect = { x: 0, y: 0, w: W, h: H };
1047
+ const groups = squarify(roots.map((r, k) => ({ ...r, k })), area);
1048
+ return html`<div class="treemap">${groups.map((g) => {
1049
+ const c = colour(g.k);
1050
+ const kids = g.children.filter((x) => x.total > 0);
1051
+ if (!kids.length) return mark('div', g.i, null, `tm-cell tm-leaf ${c} ${geo(g, area)}`, tip(g, []), html`<span class="tm-label">${g.label}</span><span class="tm-value">${fmt.format(g.total)}</span>`, true, true);
1052
+ // a title strip when the group is tall enough, the children below it
1053
+ const head = g.h > 14 ? Math.min(7, g.h * 0.2) : 0;
1054
+ const inner: Rect = { x: g.x, y: g.y + head, w: g.w, h: g.h - head };
1055
+ const cells = squarify(kids, inner);
1056
+ return html`<div class="tm-group ${c} ${geo(g, area)}">${head ? html`<span class="tm-title">${g.label}</span>` : ''}${cells.map((x) =>
1057
+ mark('div', x.i, null, `tm-cell ${geo(x, g)}`, tip(x, [g.label]), html`<span class="tm-label">${x.label}</span><span class="tm-value">${fmt.format(x.total)}</span>`, true, true))}</div>`;
1058
+ })}</div>`;
1059
+ }
1060
+
1061
+ function sunburst(roots: TreeNode[], colour: (k: number) => string, tip: (n: TreeNode, chain: string[]) => string, centre: string, total: number) {
1062
+ // rings from radius 6 (the centre shows the total) to 20 in a 42 × 42 box
1063
+ const depth = Math.min(MAX_TREE_DEPTH, Math.max(...roots.map(function d(n: TreeNode): number {
1064
+ return 1 + Math.max(0, ...n.children.filter((c) => c.total > 0).map(d));
1065
+ })));
1066
+ const R0 = 6.5;
1067
+ const ring = (20 - R0) / depth;
1068
+ const pt = (r: number, a: number) => `${(21 + r * Math.sin(a)).toFixed(3)},${(21 - r * Math.cos(a)).toFixed(3)}`;
1069
+ const sector = (a0: number, a1: number, r1: number, r2: number) => {
1070
+ const sweep = Math.min(a1 - a0, 2 * Math.PI - 1e-4);
1071
+ const e = a0 + sweep;
1072
+ const large = sweep > Math.PI ? 1 : 0;
1073
+ return `M${pt(r1, a0)} A${r1},${r1} 0 ${large} 1 ${pt(r1, e)} L${pt(r2, e)} A${r2},${r2} 0 ${large} 0 ${pt(r2, a0)} Z`;
1074
+ };
1075
+ const arcs: Raw[] = [];
1076
+ const draw = (n: TreeNode, a0: number, a1: number, level: number, c: string, chain: string[]) => {
1077
+ const r1 = R0 + level * ring;
1078
+ let arc = html`<path class="sb ${c} d${String(level + 1)}" d="${sector(a0, a1, r1 + 0.15, r1 + ring)}"><title>${tip(n, chain)}</title></path>`;
1079
+ const h = link?.(n.i, null);
1080
+ if (h) arc = html`<a class="drill" ${h} tabindex="-1">${arc}</a>`;
1081
+ arcs.push(arc);
1082
+ if (level + 1 >= depth) return;
1083
+ let a = a0;
1084
+ for (const k of n.children.filter((x) => x.total > 0)) {
1085
+ const b = a + ((a1 - a0) * k.total) / n.total;
1086
+ draw(k, a, b, level + 1, c, [...chain, n.label]);
1087
+ a = b;
1088
+ }
1089
+ };
1090
+ let a = 0;
1091
+ roots.forEach((r, k) => {
1092
+ const b = a + (2 * Math.PI * r.total) / total;
1093
+ draw(r, a, b, 0, colour(k), []);
1094
+ a = b;
1095
+ });
1096
+ return html`<div class="chart-donut chart-sunburst">
1097
+ <div class="donut-figure">
1098
+ <svg viewBox="0 0 42 42" aria-hidden="true">${arcs}</svg>
1099
+ <div class="donut-center"><b>${compact.format(total)}</b><span>${centre}</span></div>
1100
+ </div>
1101
+ <ul class="chart-legend donut-legend">${roots.map((r, k) => {
1102
+ const inner = html`<span class="swatch ${colour(k)}"></span><span class="lg-label">${r.label}</span><span class="lg-value">${fmt.format(r.total)} · ${((r.total / total) * 100).toFixed(0)}%</span>`;
1103
+ const h = link?.(r.i, null);
1104
+ return html`<li data-tip="${tip(r, [])}">${h ? html`<a class="drill" ${h}>${inner}</a>` : inner}</li>`;
1105
+ })}</ul>
1106
+ </div>`;
1107
+ }
1108
+
1109
+ // ---------------------------------------------------------------- timeline (sprint 70)
1110
+ // label, start, end (empty or equal: a point in time); optional column by name: series
1111
+ // (a lane per series). Items that overlap in a lane get rows of their own.
1112
+ function timeline(title: string, rows: unknown[][], fields: { name: string }[]) {
1113
+ if (fields.length < 2) return html`<p class="empty">${texts.noTimeline}</p>`;
1114
+ const cSeries = fields.findIndex((f, i) => i > 1 && f.name.toLowerCase() === 'series');
1115
+ const hasEnd = fields.length > 2 && cSeries !== 2;
1116
+ const items = rows
1117
+ .map((r, i) => {
1118
+ const start = wallClock(r[1]);
1119
+ const end = hasEnd ? wallClock(r[2]) : NaN;
1120
+ return { i, label: cell(r[0]), start, end: Number.isFinite(end) && end > start ? end : start, series: cSeries >= 0 ? cell(r[cSeries]) : '' };
1121
+ })
1122
+ .filter((t) => Number.isFinite(t.start))
1123
+ .sort((a, b) => a.start - b.start || a.i - b.i);
1124
+ if (!items.length) return html`<p class="empty">${texts.noTimeline}</p>`;
1125
+ const first = Math.min(...items.map((t) => t.start));
1126
+ const last = Math.max(...items.map((t) => t.end));
1127
+ const scale = timeScale(first, last + Math.max((last - first) * 0.03, HOUR));
1128
+ const span = scale.hi - scale.lo;
1129
+ const x = (t: number) => ((t - scale.lo) / span) * 100;
1130
+ const dateOnly = dateFormat({ dateStyle: 'medium' });
1131
+ const dateTime = dateFormat({ dateStyle: 'medium', timeStyle: 'short' });
1132
+ const when = (t: number) => (t % DAY ? dateTime.format(t) : dateOnly.format(t));
1133
+ const tickFmt = dateFormat(
1134
+ scale.unit === 'hour' ? { hour: '2-digit', minute: '2-digit' } : scale.unit === 'month' ? { month: 'short', year: '2-digit' } : scale.unit === 'year' ? { year: 'numeric' } : { day: 'numeric', month: 'short' },
1135
+ );
1136
+ const lanes = [...new Set(items.map((t) => t.series))];
1137
+ // rows within a lane: an item takes the first row free from its start (its label needs room too)
1138
+ const room = span * 0.16;
1139
+ const laid = lanes.map((name) => {
1140
+ const ends: number[] = [];
1141
+ const placed = items.filter((t) => t.series === name).map((t) => {
1142
+ let row = ends.findIndex((e) => e <= t.start);
1143
+ if (row < 0) row = ends.push(0) - 1;
1144
+ ends[row] = Math.max(t.end, t.start + room);
1145
+ return { t, row };
1146
+ });
1147
+ return { name, rows: Math.max(1, ends.length), placed };
1148
+ });
1149
+ const tipOf = (t: (typeof items)[number]) => `${t.series ? `${t.series} · ` : ''}${t.label}: ${t.end > t.start ? `${when(t.start)} – ${when(t.end)}` : when(t.start)}`;
1150
+ const everyTick = Math.ceil(scale.ticks.length / 8);
1151
+ return html`<div class="timeline">
1152
+ <div class="timeline-axis" aria-hidden="true">${scale.ticks.slice(0, -1).map(
1153
+ (t, k) => html`<span class="${k % everyTick ? 'skip ' : (k / everyTick) % 2 ? 'odd ' : ''}${css.cls(`left:${pct(x(t))}`)}">${tickFmt.format(t)}</span>`,
1154
+ )}</div>
1155
+ ${laid.map((lane, k) => html`<div class="timeline-lane s${String((k % MAX_SERIES) + 1)}">
1156
+ ${lane.name ? html`<span class="timeline-name">${lane.name}</span>` : ''}
1157
+ <div class="timeline-plot ${css.cls(`height:${(lane.rows * 1.9).toFixed(1)}rem`)}">
1158
+ ${scale.ticks.map((t) => html`<span class="gantt-grid ${css.cls(`left:${pct(x(t))}`)}" aria-hidden="true"></span>`)}
1159
+ ${lane.placed.map(({ t, row }) => {
1160
+ const top = css.cls(`top:${(row * 1.9 + 0.3).toFixed(1)}rem`);
1161
+ return t.end > t.start
1162
+ ? mark('span', t.i, null, `tl-item ${top} ${css.cls(`left:${pct(x(t.start))};width:${pct(x(t.end) - x(t.start))}`)}`, tipOf(t), html`<span class="tl-label">${t.label}</span>`, true, true)
1163
+ : mark('span', t.i, null, `tl-point ${top} ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), html`<span class="tl-dot" aria-hidden="true"></span><span class="tl-label">${t.label}</span>`, true, true);
1164
+ })}
1165
+ </div></div>`)}
1166
+ </div>
1167
+ <details class="chart-data"><summary>${texts.table}</summary>
1168
+ <div class="table-wrap"><table class="report">
1169
+ <caption class="sr-only">${title}</caption>
1170
+ <thead><tr><th scope="col">${fields[0].name}</th>${cSeries >= 0 ? html`<th scope="col">${fields[cSeries].name}</th>` : ''}<th scope="col">${fields[1].name}</th>${hasEnd ? html`<th scope="col">${fields[2].name}</th>` : ''}</tr></thead>
1171
+ <tbody>${items.map((t) => {
1172
+ const l = link?.(t.i, null);
1173
+ return html`<tr><th scope="row">${l ? html`<a ${l}>${t.label}</a>` : t.label}</th>${cSeries >= 0 ? html`<td>${t.series}</td>` : ''}<td>${when(t.start)}</td>${hasEnd ? html`<td>${t.end > t.start ? when(t.end) : ''}</td>` : ''}</tr>`;
1174
+ })}</tbody>
1175
+ </table></div></details>`;
1176
+ }
1177
+
899
1178
  /** A range chart's legend: one entry per pair of series. */
900
1179
  const rangeNames = (series: Series[]): Series[] =>
901
1180
  Array.from({ length: Math.min(4, Math.floor(series.length / 2)) }, (_, k) => ({ name: `${series[2 * k].name} – ${series[2 * k + 1].name}`, values: [] }));
@@ -918,12 +1197,17 @@ export function renderChartBody(kind: ChartKind, title: string, rows: unknown[][
918
1197
  ...texts, table: t('chart.table'), other: t('chart.other'), label: t('chart.label'), noBubble: t('chart.no_bubble'), noRadar: t('chart.no_radar'),
919
1198
  good: t('chart.status_good'), warning: t('chart.status_warning'), critical: t('chart.status_critical'), ofFirst: t('chart.of_first'), size: t('chart.size'),
920
1199
  noGantt: t('chart.no_gantt'), noData: t('chart.no_data'), today: t('chart.today'),
921
- noRange: t('chart.no_range'), noBoxplot: t('chart.no_boxplot'), noStock: t('chart.no_stock'),
1200
+ noRange: t('chart.no_range'), noBoxplot: t('chart.no_boxplot'), noStock: t('chart.no_stock'), ganttHint: t('chart.gantt_hint'), noTree: t('chart.no_tree'), noTimeline: t('chart.no_timeline'), total: t('chart.total'),
922
1201
  min: t('chart.min'), q1: t('chart.q1'), median: t('chart.median'), q3: t('chart.q3'), max: t('chart.max'),
923
1202
  };
924
1203
  // a Gantt chart reads its date columns itself (its series are not numbers)
925
1204
  if (kind === 'gantt')
926
- return html`<figure class="chart chart-gantt" aria-label="${title}">${gantt(title, rows, fields, opts.now ?? Date.now())}</figure>`;
1205
+ return html`<figure class="chart chart-gantt" aria-label="${title}">${gantt(title, rows, fields, opts.now ?? Date.now(), opts.edit)}</figure>`;
1206
+ // (sprint 70) hierarchies and timelines read their columns by position and name, like a Gantt chart
1207
+ if (kind === 'treemap' || kind === 'sunburst')
1208
+ return html`<figure class="chart chart-${kind}" aria-label="${title}">${hierarchy(kind, title, rows, fields)}</figure>`;
1209
+ if (kind === 'timeline')
1210
+ return html`<figure class="chart chart-timeline" aria-label="${title}">${timeline(title, rows, fields)}</figure>`;
927
1211
  const { labels, series } = parse(rows, fields, opts.hidden);
928
1212
  if (!series.length) return html`<p class="empty">${texts.noSeries}</p>`;
929
1213
  if (kind === 'boxplot') {
@@ -284,42 +284,74 @@ export async function renderDataReporter(ctx: PageContext, r: Region): Promise<R
284
284
  return reportView(ctx, r, source, cols, def, saved, edited);
285
285
  }
286
286
 
287
- /** The reports the user may open, and a form to start a new one. */
287
+ /**
288
+ * The Data Reporter's welcome page (APEX 26.1): a greeting, the data sources as
289
+ * cards that start a report, a search over the reports, the user's own reports
290
+ * (latest changed first) and the ones others shared. config.welcome = false
291
+ * keeps the compact form (a list of sources and a Start button); welcome_title
292
+ * and welcome_text replace the greeting.
293
+ */
288
294
  async function home(ctx: PageContext, r: Region, sources: Source[]): Promise<Raw> {
289
295
  const t = ctx.locale.t;
290
296
  const c = ctx.client!;
291
297
  const prefix = prefixOf(r);
298
+ const q = (ctx.params.get(`${prefix}q`) ?? '').trim().slice(0, 100);
292
299
  const reports = (
293
300
  await savepoint(c, () =>
294
- c.query<Saved>('select id, name, description, username, shared, own, definition from meta.data_reports where region_id = $1 order by own desc, lower(name), id', [r.id]),
301
+ c.query<Saved & { updated_at: string }>(
302
+ `select id, name, description, username, shared, own, definition, updated_at from meta.data_reports
303
+ where region_id = $1 and ($2 = '' or position(lower($2) in lower(name || ' ' || coalesce(description, ''))) > 0)
304
+ order by own desc, case when own then updated_at end desc, lower(name), id`, [r.id, q]),
295
305
  )
296
306
  ).rows;
297
307
  const labelOf = (id: string) => {
298
308
  const s = sources.find((x) => x.id === id);
299
309
  return s ? ctx.locale.tr(s.label || heading(s.table)) : id;
300
310
  };
301
- const list = (rows: Saved[]) =>
311
+ const list = (rows: (Saved & { updated_at: string })[]) =>
302
312
  html`<ul class="reporter-list">${rows.map((x) => html`<li>
303
313
  <a href="${url(ctx, r, new URLSearchParams([[`${prefix}open`, String(x.id)]]))}">${x.name}</a>
304
- <span class="muted">${labelOf(str(x.definition?.source))}${x.own ? '' : html` · ${t('reporter.by', { user: x.username })}`}</span>${x.own && x.shared ? html` <span class="tag">${t('reporter.shared_tag')}</span>` : ''}
314
+ <span class="muted">${labelOf(str(x.definition?.source))}${x.own ? html` · ${t('reporter.changed', { date: String(x.updated_at).slice(0, 10) })}` : html` · ${t('reporter.by', { user: x.username })}`}</span>${x.definition?.chart ? html` <span class="tag">${t('reporter.chart_tag')}</span>` : ''}${x.own && x.shared ? html` <span class="tag">${t('reporter.shared_tag')}</span>` : ''}
305
315
  ${x.description ? html`<p class="muted reporter-desc">${x.description}</p>` : ''}
306
316
  </li>`)}</ul>`;
307
317
  const mine = reports.filter((x) => x.own);
308
318
  const others = reports.filter((x) => !x.own);
309
- const form = `drn${r.id}`;
310
319
  const keep = [...ctx.params.entries()].filter(([k]) => !k.startsWith(prefix) && !['clear', 'cs'].includes(k));
311
- ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}</form>`);
312
- return html`<div class="reporter">
313
- <div class="reporter-new">
314
- <label class="label" for="${form}_src">${t('reporter.new')}</label>
315
- <div class="reporter-row">
316
- ${sel(`${prefix}src`, form, t('reporter.source'), sources.map((s) => [s.id, ctx.locale.tr(s.label || heading(s.table))]), sources[0].id, `${form}_src`)}
317
- <button class="btn btn-hot" form="${form}">${t('reporter.create')}</button>
320
+ const hidden = keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`);
321
+ const lists = html`${ctx.user !== 'nobody' ? html`<h3 class="reporter-h">${t('reporter.my_reports')}</h3>${mine.length ? list(mine) : html`<p class="muted">${q ? t('reporter.no_match') : t('reporter.no_reports')}</p>`}` : ''}
322
+ ${others.length ? html`<h3 class="reporter-h">${t('reporter.shared_reports')}</h3>${list(others)}` : ''}`;
323
+
324
+ if (r.config.welcome === false) {
325
+ const form = `drn${r.id}`;
326
+ ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hidden}</form>`);
327
+ return html`<div class="reporter">
328
+ <div class="reporter-new">
329
+ <label class="label" for="${form}_src">${t('reporter.new')}</label>
330
+ <div class="reporter-row">
331
+ ${sel(`${prefix}src`, form, t('reporter.source'), sources.map((s) => [s.id, ctx.locale.tr(s.label || heading(s.table))]), sources[0].id, `${form}_src`)}
332
+ <button class="btn btn-hot" form="${form}">${t('reporter.create')}</button>
333
+ </div>
334
+ ${sources.some((s) => s.description) ? html`<dl class="reporter-sources">${sources.filter((s) => s.description).map((s) => html`<dt>${ctx.locale.tr(s.label || heading(s.table))}</dt><dd>${ctx.locale.tr(s.description!)}</dd>`)}</dl>` : ''}
318
335
  </div>
319
- ${sources.some((s) => s.description) ? html`<dl class="reporter-sources">${sources.filter((s) => s.description).map((s) => html`<dt>${ctx.locale.tr(s.label || heading(s.table))}</dt><dd>${ctx.locale.tr(s.description!)}</dd>`)}</dl>` : ''}
320
- </div>
321
- ${ctx.user !== 'nobody' ? html`<h3 class="reporter-h">${t('reporter.my_reports')}</h3>${mine.length ? list(mine) : html`<p class="muted">${t('reporter.no_reports')}</p>`}` : ''}
322
- ${others.length ? html`<h3 class="reporter-h">${t('reporter.shared_reports')}</h3>${list(others)}` : ''}
336
+ ${lists}
337
+ </div>`;
338
+ }
339
+
340
+ const title = typeof r.config.welcome_title === 'string' && r.config.welcome_title.trim() ? ctx.locale.tr(r.config.welcome_title) : t('reporter.welcome_title');
341
+ const text = typeof r.config.welcome_text === 'string' && r.config.welcome_text.trim() ? ctx.locale.tr(r.config.welcome_text) : t('reporter.welcome_text');
342
+ // the search: a GET form of its own (detached: the region may sit inside the page's form)
343
+ const search = `drq${r.id}`;
344
+ const searchable = q !== '' || reports.length > 6;
345
+ if (searchable) ctx.detached.push(html`<form id="${search}" method="get" action="${ctx.base}/${ctx.page.page_no}" role="search">${hidden}</form>`);
346
+ return html`<div class="reporter reporter-welcome">
347
+ <div class="reporter-hello"><h3 class="reporter-title">${title}</h3><p class="muted">${text}</p></div>
348
+ <h3 class="reporter-h">${t('reporter.new')}</h3>
349
+ <ul class="reporter-cards">${sources.map((s) => html`<li><a class="reporter-card" href="${url(ctx, r, new URLSearchParams([[`${prefix}src`, s.id]]))}">
350
+ <strong>${ctx.locale.tr(s.label || heading(s.table))}</strong>
351
+ ${s.description ? html`<span class="muted">${ctx.locale.tr(s.description)}</span>` : ''}
352
+ <span class="reporter-card-meta">${t('reporter.columns_count', { n: s.columns.length })}</span></a></li>`)}</ul>
353
+ ${searchable ? html`<div class="reporter-row reporter-search"><input type="search" name="${prefix}q" value="${q}" form="${search}" aria-label="${t('reporter.search')}" placeholder="${t('reporter.search')}"><button class="btn" form="${search}">${t('reporter.search')}</button></div>` : ''}
354
+ ${lists}
323
355
  </div>`;
324
356
  }
325
357
 
@@ -0,0 +1,63 @@
1
+ import pg from 'pg';
2
+ import { applyBinds, literal } from '../binds.ts';
3
+ import { savepoint } from '../db.ts';
4
+ import type { Region } from '../metadata.ts';
5
+ import { Forbidden, isAuthorized } from './authz.ts';
6
+ import { wallClock } from './charts.ts';
7
+ import { bindValues, stripSemicolon, toState, type PageContext } from './context.ts';
8
+ import { resolveRestRegion } from './rest-sources.ts';
9
+
10
+ // Gantt chart drag and drop (sprint 69; APEX: the Gantt chart's task
11
+ // dragging): a chart region of kind "gantt" with config.gantt_move (SQL, e.g.
12
+ // an UPDATE or a function call) lets users drag a task to new dates or drag
13
+ // its end. POST /a/<app>/<page>/chart/<region>/move (routes.ts) checks the
14
+ // CSRF token, page access and the region's visibility; moveGanttTask() then
15
+ // checks config.gantt_move_authz, the dates, and that the task is one of the
16
+ // rows the region's query shows this user (as the application's role, so row
17
+ // level security applies), and runs the SQL with :TASK_ID, :NEW_START and
18
+ // :NEW_END in a savepoint of the request's transaction.
19
+
20
+ /** YYYY-MM-DD or YYYY-MM-DDTHH:MM (a day or an hour of the chart's time axis). */
21
+ const WHEN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/;
22
+
23
+ /** A target date's wall clock in ms, or NaN (also for impossible dates and years outside 1900–2200). */
24
+ export function targetTime(v: string): number {
25
+ const m = WHEN.exec(v);
26
+ if (!m || +m[1] < 1900 || +m[1] > 2200) return NaN;
27
+ const t = wallClock(v.replace('T', ' '));
28
+ // 2026-02-30 rolls over in Date.UTC: refuse what doesn't read back the same
29
+ const d = new Date(t);
30
+ return d.getUTCDate() === +m[3] && d.getUTCMonth() === +m[2] - 1 && (m[4] === undefined || (d.getUTCHours() === +m[4] && d.getUTCMinutes() === +m[5])) ? t : NaN;
31
+ }
32
+
33
+ /** May this user drag the region's tasks? */
34
+ export async function ganttEditable(ctx: PageContext, r: Region) {
35
+ const cfg = r.config as Record<string, any>;
36
+ return cfg.kind === 'gantt' && typeof cfg.gantt_move === 'string' && cfg.gantt_move.trim() !== '' && (await isAuthorized(ctx, cfg.gantt_move_authz ?? null));
37
+ }
38
+
39
+ /**
40
+ * Give task `task` of a Gantt region the dates `start` and `end`. Throws Forbidden
41
+ * when dragging isn't allowed or the task isn't one this user sees, RangeError for
42
+ * dates that aren't valid. Returns the task's label (the query's first column).
43
+ */
44
+ export async function moveGanttTask(ctx: PageContext, r: Region, task: string, start: string, end: string) {
45
+ const t = ctx.locale.t;
46
+ if (!(await ganttEditable(ctx, r))) throw new Forbidden(t('chart.gantt_cannot_move'));
47
+ const s = targetTime(start);
48
+ const e = targetTime(end);
49
+ if (!Number.isFinite(s) || !Number.isFinite(e) || e < s) throw new RangeError(t('chart.gantt_bad_dates'));
50
+ const c = ctx.client!;
51
+ await resolveRestRegion(ctx, r);
52
+ const src = stripSemicolon(applyBinds(r.source ?? '', bindValues(ctx)));
53
+ // the task_id column as the query names it (the chart finds it case-insensitively)
54
+ const shape = await savepoint(c, () => c.query(`select * from (\n${src}\n) "__q" limit 0`));
55
+ const idCol = shape.fields.find((f, i) => i > 2 && f.name.toLowerCase() === 'task_id');
56
+ if (!idCol) throw new Forbidden(t('chart.gantt_cannot_move'));
57
+ const res = await savepoint(c, () =>
58
+ c.query({ text: `select * from (\n${src}\n) "__q" where "__q".${pg.escapeIdentifier(idCol.name)}::text = ${literal(task)} limit 2`, rowMode: 'array' }));
59
+ if (res.rows.length !== 1) throw new Forbidden(t('chart.gantt_cannot_move'));
60
+ const sql = stripSemicolon(applyBinds((r.config as Record<string, any>).gantt_move, { ...bindValues(ctx), TASK_ID: task, NEW_START: start, NEW_END: end }));
61
+ await savepoint(c, () => c.query(sql));
62
+ return { label: toState(res.rows[0][0]) ?? task };
63
+ }
@@ -1,4 +1,5 @@
1
1
  import type { QueryResult } from 'pg';
2
+ import { ganttEditable } from './gantt-move.ts';
2
3
  import { applyBinds } from '../binds.ts';
3
4
  import { savepoint } from '../db.ts';
4
5
  import { esc, html, raw, type Raw } from '../html.ts';
@@ -145,6 +146,8 @@ async function renderChart(ctx: PageContext, r: Region) {
145
146
  ...chartFormat(ctx, r),
146
147
  // the query's timestamps are in the session's time zone: so is "today"
147
148
  ...(kind === 'gantt' ? { now: wallClockIn(ctx.locale.timeZone ?? (await databaseTimeZone())) } : {}),
149
+ // (sprint 69) tasks this user may drag
150
+ ...(kind === 'gantt' && (await ganttEditable(ctx, r)) ? { edit: { region: r.id, snap: r.config.gantt_snap === 'hour' || r.config.gantt_snap === 'day' ? r.config.gantt_snap : undefined } } : {}),
148
151
  })}${firstRows(ctx, res)}`;
149
152
  }
150
153
 
@@ -30,6 +30,7 @@ import { cleanRichText } from '../richtext.ts';
30
30
  import { applyUploads, fileRoutes, readMultipart, type Upload } from './files.ts';
31
31
  import { renderRegion } from './regions.ts';
32
32
  import { downloadHeaders, type Download } from './processes.ts';
33
+ import { moveGanttTask } from './gantt-move.ts';
33
34
  import { moveCalendarEvent } from './calendar.ts';
34
35
  import { openDownload, reportParams, normaliseReportParams, selectionOf } from './report.ts';
35
36
  import { invalidatePage, lazyOf } from './region-cache.ts';
@@ -1038,6 +1039,44 @@ export async function runtimeRoutes(app: FastifyInstance) {
1038
1039
  }
1039
1040
  });
1040
1041
 
1042
+ // (sprint 69) Gantt drag and drop (gantt-move.ts): new dates for a task, answer with the re-rendered region.
1043
+ app.post('/a/:alias/:page/chart/:id/move', async (req: Req, reply) => {
1044
+ const ctx = await loadContext(req, reply, { json: true });
1045
+ if (!ctx) return;
1046
+ const body = req.body ?? {};
1047
+ if (body.__csrf !== ctx.session.csrf_token) return reply.code(403).send({ error: ctx.locale.t('error.session_reload') });
1048
+ ctx.params = new URLSearchParams(typeof body.__url_params === 'string' ? body.__url_params : '');
1049
+ const task = typeof body.task === 'string' ? body.task : '';
1050
+ const start = typeof body.start === 'string' ? body.start : '';
1051
+ const end = typeof body.end === 'string' ? body.end : '';
1052
+ try {
1053
+ const out = await appTx(txContext(ctx), async (c) => {
1054
+ ctx.client = c;
1055
+ await checkPageAccess(ctx);
1056
+ const vis = await computeVisibility(ctx);
1057
+ const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'chart');
1058
+ if (!r || !vis.regions.has(r.id) || !task || task.length > 200) throw new Forbidden(ctx.locale.t('chart.gantt_cannot_move'));
1059
+ const moved = await moveGanttTask(ctx, r, task, start, end);
1060
+ await computeVisibility(ctx);
1061
+ return {
1062
+ region: (await renderRegion(ctx, r)).toString(),
1063
+ css: ctx.css.text,
1064
+ message: ctx.locale.t('chart.gantt_moved', { task: moved.label, start: start.replace('T', ' '), end: end.replace('T', ' ') }),
1065
+ };
1066
+ });
1067
+ await saveState(ctx.session);
1068
+ await logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'gantt_move', ip: ctx.ip, detail: `region ${req.params.id}, task ${task}: ${start} – ${end}` });
1069
+ return reply.send(out);
1070
+ } catch (e) {
1071
+ if (e instanceof Forbidden) {
1072
+ await logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail: `gantt move: region ${req.params.id}` });
1073
+ return reply.code(403).send({ error: e.message });
1074
+ }
1075
+ if (e instanceof RangeError) return reply.code(400).send({ error: e.message });
1076
+ return reply.code(400).send({ error: await publicError(ctx, e, 'gantt move') });
1077
+ }
1078
+ });
1079
+
1041
1080
  // ---------------------------------------------------------------- login / logout
1042
1081
  const loginPage = async (app: App, locale: Locale, session: Session, next: string, error?: string) => {
1043
1082
  const t = locale.t;