@gafj/gafj 0.1.1

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 (121) hide show
  1. package/README.md +99 -0
  2. package/bin/cli.js +628 -0
  3. package/bridge/byo_key.js +50 -0
  4. package/bridge/credits.js +72 -0
  5. package/bridge/paste.js +44 -0
  6. package/bridge/providers/anthropic.js +16 -0
  7. package/bridge/providers/gemini.js +15 -0
  8. package/bridge/providers/index.js +39 -0
  9. package/bridge/providers/openai_compatible.js +17 -0
  10. package/core/extract.js +55 -0
  11. package/core/generators/excerpt.js +64 -0
  12. package/core/generators/packet.js +197 -0
  13. package/core/generators/serialize.js +56 -0
  14. package/core/generators/slice.js +61 -0
  15. package/core/house_rules.js +49 -0
  16. package/core/kb_import.js +257 -0
  17. package/core/migrations/002-profile.sql +3 -0
  18. package/core/migrations/003-discover.sql +49 -0
  19. package/core/parsers.js +79 -0
  20. package/core/practice.js +75 -0
  21. package/core/provenance.js +90 -0
  22. package/core/qr.js +185 -0
  23. package/core/rules/categories.md +5 -0
  24. package/core/rules/deep_answers.md +7 -0
  25. package/core/rules/discover.md +30 -0
  26. package/core/rules/extract.md +31 -0
  27. package/core/rules/followup.md +9 -0
  28. package/core/rules/index.js +32 -0
  29. package/core/rules/onepager.md +15 -0
  30. package/core/rules/practice.md +10 -0
  31. package/core/rules/prep.md +47 -0
  32. package/core/rules/resume.md +49 -0
  33. package/core/rules/rubric.md +120 -0
  34. package/core/schema.sql +342 -0
  35. package/core/schemas/categories.js +16 -0
  36. package/core/schemas/cover.js +10 -0
  37. package/core/schemas/deep_answers.js +16 -0
  38. package/core/schemas/discover.js +19 -0
  39. package/core/schemas/extract.js +32 -0
  40. package/core/schemas/followup.js +10 -0
  41. package/core/schemas/index.js +32 -0
  42. package/core/schemas/lang.js +111 -0
  43. package/core/schemas/onepager.js +26 -0
  44. package/core/schemas/practice.js +18 -0
  45. package/core/schemas/prep.js +15 -0
  46. package/core/schemas/resume.js +25 -0
  47. package/core/scoring.js +586 -0
  48. package/core/text.js +98 -0
  49. package/core/tracker_map.js +94 -0
  50. package/core/ulid.js +38 -0
  51. package/core/validators/highlight_body_match.js +49 -0
  52. package/core/validators/index.js +43 -0
  53. package/core/validators/metric_pairing.js +52 -0
  54. package/core/validators/no_confabulated_history.js +63 -0
  55. package/core/validators/no_dashes.js +31 -0
  56. package/core/validators/no_derived_tenure.js +60 -0
  57. package/core/validators/style_config.js +18 -0
  58. package/core/validators/verb_ladder.js +51 -0
  59. package/core/verbs.js +43 -0
  60. package/http/api.js +260 -0
  61. package/http/guard.js +106 -0
  62. package/http/headers.js +17 -0
  63. package/http/hosted.js +273 -0
  64. package/http/identity.js +70 -0
  65. package/http/server.js +136 -0
  66. package/http/sse.js +41 -0
  67. package/mcp/server.js +25 -0
  68. package/mcp/tools.js +237 -0
  69. package/package.json +59 -0
  70. package/store/backup.js +63 -0
  71. package/store/backup_dir.js +60 -0
  72. package/store/config.js +32 -0
  73. package/store/context.js +74 -0
  74. package/store/db.js +37 -0
  75. package/store/discover.js +120 -0
  76. package/store/events.js +28 -0
  77. package/store/export_xlsx.js +62 -0
  78. package/store/home.js +36 -0
  79. package/store/import_kb.js +88 -0
  80. package/store/import_tracker.js +195 -0
  81. package/store/ingest.js +140 -0
  82. package/store/kb.js +202 -0
  83. package/store/log.js +88 -0
  84. package/store/migrate.js +55 -0
  85. package/store/onboarding.js +267 -0
  86. package/store/packets.js +110 -0
  87. package/store/practice.js +52 -0
  88. package/store/proposals.js +119 -0
  89. package/store/reads.js +110 -0
  90. package/store/render.js +111 -0
  91. package/store/reply.js +19 -0
  92. package/store/review.js +64 -0
  93. package/store/scoring.js +134 -0
  94. package/store/settings.js +142 -0
  95. package/store/snapshot.js +88 -0
  96. package/store/transitions.js +391 -0
  97. package/store/tx.js +71 -0
  98. package/store/tz.js +24 -0
  99. package/ui/app.css +137 -0
  100. package/ui/app.js +75 -0
  101. package/ui/fonts/plexmono-400.woff2 +0 -0
  102. package/ui/fonts/plexmono-500.woff2 +0 -0
  103. package/ui/fonts/plexmono-600.woff2 +0 -0
  104. package/ui/fonts/spacegrotesk-var.woff2 +0 -0
  105. package/ui/hosted/signin.css +8 -0
  106. package/ui/hosted/signin.html +30 -0
  107. package/ui/hosted/signin.js +28 -0
  108. package/ui/icon-192.png +0 -0
  109. package/ui/icon-512.png +0 -0
  110. package/ui/index.html +20 -0
  111. package/ui/lib.js +92 -0
  112. package/ui/manifest.webmanifest +18 -0
  113. package/ui/screens/application.js +73 -0
  114. package/ui/screens/dashboard.js +37 -0
  115. package/ui/screens/document.js +64 -0
  116. package/ui/screens/interview.js +85 -0
  117. package/ui/screens/kb.js +142 -0
  118. package/ui/screens/packet.js +122 -0
  119. package/ui/screens/postings.js +92 -0
  120. package/ui/screens/settings.js +115 -0
  121. package/ui/vendor/preact.mjs +1 -0
package/ui/app.css ADDED
@@ -0,0 +1,137 @@
1
+ /* GAF-J visual system: ink rail + warm paper (light, default) and ink rail + graphite canvas (dark, by choice).
2
+ The rail is a brand constant — #14171c in both modes, never themed. Fonts are self-hosted
3
+ (ui/fonts/*.woff2, latin subset only) so the CSP's style-src/font-src 'self' never has to loosen. */
4
+
5
+ @font-face { font-family: "Space Grotesk"; font-style: normal; font-weight: 500 700; font-display: swap; src: url("/fonts/spacegrotesk-var.woff2") format("woff2"); }
6
+ @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/plexmono-400.woff2") format("woff2"); }
7
+ @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/plexmono-500.woff2") format("woff2"); }
8
+ @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/plexmono-600.woff2") format("woff2"); }
9
+
10
+ :root {
11
+ --sans: "Space Grotesk", ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
12
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ --rail: #14171c; --rail-active: #20242c; --rail-text: #e6e8ec; --rail-muted: #8b90a0; --rail-dim: #5b6070;
14
+
15
+ /* light (default) */
16
+ --canvas: #f6f4ef; --surface: #ffffff; --surface-2: #ffffff;
17
+ --line: #e3ddd0; --line-2: #d9d2c4;
18
+ --ink: #1c1f26; --muted: #6b7280; --dim: #8a8477;
19
+ --accent: #0e8f83; --accent-ink: #ffffff; --accent-hover: #0b746a;
20
+ --good: #1f9d5c; --good-bg: #dff3e6; --good-bright: #146942;
21
+ --warn: #b5750d; --warn-bg: #f5e6cc;
22
+ --bad: #c8392c; --bad-bg: #f6dedb; --bad-bright: #8f261c;
23
+ --pin: #7c4fd1; --pin-bg: #ece3fa;
24
+ --shadow: 0 20px 60px -35px #0003;
25
+ }
26
+ /* dark is a choice, never inferred from the OS */
27
+ :root[data-theme="dark"] { --canvas: #0e1015; --surface: #15171d; --surface-2: #1b1e24;
28
+ --line: #262a33; --line-2: #333744; --ink: #e9ebf0; --muted: #8b90a0; --dim: #5b6070;
29
+ --accent: #3ecfc0; --accent-ink: #0e1015; --accent-hover: #6fe0d4;
30
+ --good: #34c77b; --good-bg: #234d3a; --good-bright: #7bf0ac;
31
+ --warn: #e0a634; --warn-bg: #4a3a1c;
32
+ --bad: #e0554a; --bad-bg: #4a2b28; --bad-bright: #ff9d94;
33
+ --pin: #a578e8; --pin-bg: #3a2f52; --shadow: 0 20px 60px -30px #000; }
34
+
35
+ * { box-sizing: border-box; }
36
+ html, body { margin: 0; background: var(--canvas); color: var(--ink); font: 14px/1.5 var(--sans); }
37
+ a { color: var(--accent); text-decoration: none; transition: color .15s; }
38
+ a:hover { color: var(--accent-hover); text-decoration: underline; }
39
+ button, select, input, textarea { font: inherit; color: inherit; }
40
+ button { background: var(--surface-2); border: 1px solid var(--line-2); border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px; transition: border-color .15s, opacity .15s; }
41
+ button:hover { border-color: var(--accent); }
42
+ button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
43
+ button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
44
+ button.danger { color: var(--bad); }
45
+ button.small { padding: 5px 10px; font-size: 12px; }
46
+ button:disabled { opacity: .5; cursor: default; }
47
+ input, select, textarea { background: var(--surface-2); border: 1px solid var(--line-2); border-radius: 6px; padding: 7px 9px; }
48
+ textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size: 12px; }
49
+
50
+ .layout { display: grid; grid-template-columns: 170px 1fr; min-height: 100vh; }
51
+ .rail { background: var(--rail); color: var(--rail-text); padding: 22px 16px; display: flex; flex-direction: column; gap: 22px; }
52
+ .rail .brand { font-family: var(--mono); font-weight: 600; font-size: 15px; letter-spacing: .02em; display: flex; align-items: center; justify-content: space-between; }
53
+ .rail .brand .mark { width: 40px; height: 40px; border-radius: 9px; margin-right: 10px; border: 1px solid #333744; }
54
+ .rail .brand span { flex: 1; }
55
+ .rail nav, .rail > a { display: flex; flex-direction: column; gap: 2px; }
56
+ .rail a { color: var(--rail-muted); padding: 9px 10px; border-radius: 6px; display: block; font-size: 13.5px; }
57
+ .rail a.active { background: var(--rail-active); color: var(--rail-text); font-weight: 600; }
58
+ .rail a:hover { background: var(--rail-active); color: var(--rail-text); text-decoration: none; }
59
+ .rail .foot { margin-top: auto; font-size: 11.5px; color: var(--rail-dim); display: flex; flex-direction: column; gap: 4px; }
60
+ .rail .foot .name { color: #c7cad2; }
61
+ .rail .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--good); margin-right: 6px; }
62
+ .rail .theme-toggle { background: transparent; border: 1px solid #333744; color: var(--rail-muted); padding: 3px 6px; font-size: 11px; border-radius: 5px; }
63
+ .rail .theme-toggle:hover { border-color: var(--accent); color: var(--rail-text); }
64
+
65
+ .main { padding: 28px 32px; max-width: 1200px; background: var(--surface); }
66
+ h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
67
+ h2 { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 22px 0 8px; font-weight: 500; }
68
+ h3 { font-size: 15px; margin: 12px 0 6px; font-weight: 600; }
69
+ .sub { color: var(--muted); margin-bottom: 8px; font-family: var(--mono); font-size: 12px; }
70
+ .card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin-bottom: 10px; }
71
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
72
+ .row.between { justify-content: space-between; }
73
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
74
+ .list { list-style: none; margin: 0; padding: 0; }
75
+ .list li { padding: 12px 0; border-bottom: 1px solid var(--line); display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
76
+ .list li:last-child { border-bottom: 0; }
77
+ .tag { display: inline-block; font-family: var(--mono); font-size: 10.5px; padding: 3px 10px; border-radius: 100px; border: 1px solid var(--line-2); color: var(--muted); }
78
+ .tag.passed { color: var(--good); border-color: var(--good-bg); }
79
+ .tag.blocked { color: var(--bad); border-color: var(--bad-bg); }
80
+ .tag.warn { color: var(--warn); border-color: var(--warn); }
81
+ .tag.pin { color: var(--pin); border-color: var(--pin); }
82
+ .need { color: var(--bad); }
83
+ .need.soft { color: var(--warn); }
84
+ .muted { color: var(--muted); }
85
+ .mono { font-family: var(--mono); font-size: 12px; }
86
+ .small { font-size: 12px; }
87
+ .error { color: var(--bad); }
88
+ details summary { cursor: pointer; color: var(--accent); }
89
+ .intel { white-space: pre-wrap; margin: 6px 0 0 14px; color: var(--ink); }
90
+ form.inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
91
+ form.inline input, form.inline select { min-width: 120px; }
92
+ .modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: flex-start; justify-content: center; padding: 30px 12px; overflow: auto; z-index: 10; }
93
+ .modal { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; width: min(960px, 100%); padding: 18px 20px 22px; box-shadow: var(--shadow); }
94
+ .modal h1 { font-size: 17px; }
95
+
96
+ /* packet gate: terminal / scan-line direction. .term is a monospace status block on the
97
+ near-black canvas tone in both themes, colored by the leading VALIDATE line. */
98
+ .term { background: var(--canvas); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 8px 0; font-family: var(--mono); }
99
+ .term .headline { font-size: 12px; margin-bottom: 8px; }
100
+ .term.blocked .headline { color: var(--bad); }
101
+ .term.passed .headline { color: var(--good); }
102
+ .term .kv { font-size: 12px; color: var(--dim); line-height: 1.9; }
103
+ .term .kv b { color: var(--muted); font-weight: 500; }
104
+ .term .kv .hl { background: var(--bad-bg); color: var(--bad-bright); border-radius: 3px; padding: 0 3px; }
105
+ .block { border-left: 3px solid var(--bad); border-radius: 0 6px 6px 0; padding: 10px 12px; margin: 8px 0; background: var(--surface-2); }
106
+ .block.warn { border-left-color: var(--warn); }
107
+ .block .path { font-family: var(--mono); font-size: 11px; color: var(--dim); }
108
+ .block textarea { min-height: 60px; margin-top: 6px; }
109
+ .nearby { margin: 6px 0 0 10px; font-size: 12px; }
110
+ .nearby button { padding: 2px 7px; font-size: 12px; margin-right: 4px; }
111
+ .attempt { color: var(--muted); font-size: 12px; font-family: var(--mono); }
112
+
113
+ .doc { background: var(--surface-2); padding: 28px 36px; border: 1px solid var(--line); border-radius: 8px; max-width: 800px; }
114
+ .doc h1 { font-size: 22px; }
115
+ .doc h2 { text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); border-bottom: 1px solid var(--line); padding-bottom: 3px; margin: 16px 0 6px; font-family: var(--sans); }
116
+ .doc p { margin: 4px 0; }
117
+ .doc ol, .doc ul { margin: 4px 0; padding-left: 20px; }
118
+ .doc.onepager { font-size: 12.5px; line-height: 1.3; }
119
+ .doc.onepager h2 { margin: 10px 0 4px; font-size: 12px; }
120
+ .doc .lead { font-weight: 700; }
121
+ @media print {
122
+ :root { --canvas: #fff; --surface: #fff; --surface-2: #fff; --line: #ccc; --ink: #111; --muted: #555; --accent: #0b5fff; }
123
+ .rail, .noprint, .modal-back { display: none !important; }
124
+ .layout { display: block; }
125
+ .main { padding: 0; max-width: none; background: #fff; }
126
+ .doc { border: 0; padding: 0; max-width: none; }
127
+ .doc.onepager { font-size: 10.5pt; line-height: 1.25; }
128
+ @page { margin: 12mm; }
129
+ }
130
+ table.cats { border-collapse: collapse; width: 100%; font-size: 13px; }
131
+ table.cats th, table.cats td { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
132
+ table.cats th { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
133
+ table.cats input:not([type=checkbox]) { width: 100%; }
134
+ input.narrow { width: 76px; }
135
+ pre.wrap { white-space: pre-wrap; }
136
+ /* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
137
+ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px; padding: 0 3px; font-weight: 500; }
package/ui/app.js ADDED
@@ -0,0 +1,75 @@
1
+ import { h, html, render, useState, useEffect } from "./vendor/preact.mjs";
2
+ import { api, fmtWhen, useRoute, useLive, useTheme } from "./lib.js";
3
+ import { Dashboard } from "./screens/dashboard.js";
4
+ import { Application } from "./screens/application.js";
5
+ import { Interview } from "./screens/interview.js";
6
+ import { Document } from "./screens/document.js";
7
+ import { Postings } from "./screens/postings.js";
8
+ import { KB } from "./screens/kb.js";
9
+ import { Settings } from "./screens/settings.js";
10
+
11
+ function Rail({ route, me }) {
12
+ const is = (p) => (route.path === p || (p !== "/" && route.path.startsWith(p)) ? "active" : "");
13
+ const theme = useTheme();
14
+ return html`<nav class="rail">
15
+ <div class="brand"><img class="mark" src="/icon-192.png" alt="" width="40" height="40" /><span>GAF-J</span><button class="theme-toggle" title=${theme.dark ? "switch to light" : "switch to dark"} onClick=${theme.toggle}>${theme.dark ? "☾" : "☀"}</button></div>
16
+ <a href="#/" class=${is("/")}>Dashboard</a>
17
+ <a href="#/applications" class=${is("/applications")}>Applications</a>
18
+ <a href="#/interviews" class=${is("/interviews")}>Interviews</a>
19
+ <a href="#/postings" class=${is("/postings")}>Postings</a>
20
+ <a href="#/kb" class=${is("/kb")}>Knowledge base</a>
21
+ <a href="#/settings" class=${is("/settings")}>Settings</a>
22
+ <div class="foot">
23
+ <div class="name">${me ? me.name : ""}</div>
24
+ <div>AI: ${me ? me.route : ""}</div>
25
+ <div><span class="dot"></span>local</div>
26
+ <div>${me ? "v" + me.version : ""}</div>
27
+ </div>
28
+ </nav>`;
29
+ }
30
+
31
+ function Applications({ live }) {
32
+ const [rows, setRows] = useState(null);
33
+ useEffect(() => { api.get("/api/applications").then(setRows); }, [live]);
34
+ if (!rows) return html`<p class="muted">loading</p>`;
35
+ return html`<div><h1>Applications</h1><ul class="list">${rows.map((r) => html`<li key=${r.application_id}>
36
+ <a href=${"#/applications/" + r.application_id}>${r.company}: ${r.title}</a>
37
+ <span class="tag">${r.status}</span>${r.fit_score != null ? html`<span class="muted">fit ${r.fit_score}</span>` : ""}
38
+ ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}
39
+ </li>`)}</ul></div>`;
40
+ }
41
+
42
+ function Interviews({ live }) {
43
+ const [snap, setSnap] = useState(null);
44
+ const [apps, setApps] = useState([]);
45
+ useEffect(() => { api.get("/api/snapshot").then(setSnap); api.get("/api/applications").then(setApps); }, [live]);
46
+ if (!snap) return html`<p class="muted">loading</p>`;
47
+ return html`<div><h1>Interviews</h1>
48
+ <h2>Upcoming</h2>
49
+ <ul class="list">${snap.upcoming.map((u) => html`<li key=${u.interview_id}><a href=${"#/interviews/" + u.interview_id}>${u.company} ${u.round} ${u.format}</a><span class="muted">${fmtWhen(u.scheduled_at, u.tz)}</span><span class="muted">${u.people.join(", ")}</span></li>`)}
50
+ ${snap.upcoming.length ? "" : html`<li class="muted">nothing scheduled</li>`}</ul>
51
+ <h2>By application</h2>
52
+ <ul class="list">${apps.map((a) => html`<li key=${a.application_id}><a href=${"#/applications/" + a.application_id}>${a.company}</a><span class="muted">${a.scheduled} scheduled</span></li>`)}</ul>
53
+ </div>`;
54
+ }
55
+
56
+ function App() {
57
+ const route = useRoute();
58
+ const live = useLive();
59
+ const [me, setMe] = useState(null);
60
+ useEffect(() => { api.get("/api/me").then(setMe).catch(() => setMe({ name: "no session", route: "", version: "" })); }, []);
61
+ const seg = route.path.split("/").filter(Boolean);
62
+ let screen;
63
+ if (seg[0] === "applications" && seg[1]) screen = html`<${Application} id=${seg[1]} live=${live} />`;
64
+ else if (seg[0] === "applications") screen = html`<${Applications} live=${live} />`;
65
+ else if (seg[0] === "interviews" && seg[1]) screen = html`<${Interview} id=${seg[1]} live=${live} />`;
66
+ else if (seg[0] === "interviews") screen = html`<${Interviews} live=${live} />`;
67
+ else if (seg[0] === "documents" && seg[1]) screen = html`<${Document} id=${seg[1]} live=${live} />`;
68
+ else if (seg[0] === "postings") screen = html`<${Postings} id=${seg[1]} live=${live} />`;
69
+ else if (seg[0] === "kb") screen = html`<${KB} live=${live} />`;
70
+ else if (seg[0] === "settings") screen = html`<${Settings} live=${live} />`;
71
+ else screen = html`<${Dashboard} live=${live} />`;
72
+ return html`<div class="layout"><${Rail} route=${route} me=${me} /><main class="main">${screen}</main></div>`;
73
+ }
74
+
75
+ render(h(App), document.getElementById("app"));
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,8 @@
1
+ .signin { max-width: 420px; margin: 10vh auto; padding: 32px 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-align: center; }
2
+ .signin .mark { width: 64px; height: 64px; border-radius: 14px; margin-bottom: 12px; }
3
+ .signin h1 { font-family: var(--mono); font-size: 20px; margin: 0 0 8px; }
4
+ .signin .sub { color: var(--muted); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
5
+ .signin button.primary { width: 100%; padding: 11px; font-size: 15px; }
6
+ .signin details { text-align: left; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 12px; }
7
+ .signin textarea { min-height: 70px; font-size: 11px; }
8
+ .signin .foot { margin-top: 20px; }
@@ -0,0 +1,30 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sign in · GAF-J</title>
7
+ <link rel="icon" href="/icon-192.png" type="image/png">
8
+ <link rel="stylesheet" href="/app.css">
9
+ <link rel="stylesheet" href="/hosted/signin.css">
10
+ </head>
11
+ <body>
12
+ <main class="signin">
13
+ <img class="mark" src="/icon-192.png" alt="" width="64" height="64">
14
+ <h1>GAF-J</h1>
15
+ <p class="sub">Sign in with Google. Your record, documents, and campaign live in your account here, encrypted, yours to export or delete in one click.</p>
16
+ <button id="google" class="primary" type="button">Continue with Google</button>
17
+ <p id="status" class="small muted" hidden></p>
18
+ <details id="local">
19
+ <summary>Using the free app on your own PC?</summary>
20
+ <p class="small">Sign in here, then copy the token below into Settings, Credits, on your PC. It is good for one hour; the app asks for a new one when it expires.</p>
21
+ <textarea id="token" readonly rows="4" placeholder="sign in first"></textarea>
22
+ <div class="row"><button id="copy" type="button" class="small">copy token</button></div>
23
+ </details>
24
+ <p class="small muted foot">No password. Nothing here trains a model. By continuing you agree to the <a href="https://gaf-j.com/terms/">Terms</a> and <a href="https://gaf-j.com/privacy/">Privacy Policy</a>. Questions: <a href="mailto:admin@gaf-j.com">admin@gaf-j.com</a></p>
25
+ </main>
26
+ <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js"></script>
27
+ <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-auth-compat.js"></script>
28
+ <script src="/hosted/signin.js"></script>
29
+ </body>
30
+ </html>
@@ -0,0 +1,28 @@
1
+ /* Sign-in for the hosted app and for the local app's credits card. Firebase Auth (Google) mints an ID token;
2
+ POST /auth/session turns it into a server session cookie. Nothing else leaves this page. */
3
+ (function () {
4
+ var status = document.getElementById("status");
5
+ var say = function (t, bad) { status.hidden = !t; status.textContent = t || ""; status.className = "small " + (bad ? "error" : "muted"); };
6
+ var tokenBox = document.getElementById("token");
7
+ document.getElementById("copy").addEventListener("click", function () { tokenBox.select(); try { navigator.clipboard.writeText(tokenBox.value); say("copied"); } catch (e) { say("select and copy the token by hand"); } });
8
+ fetch("/signin/config.json", { credentials: "same-origin" }).then(function (r) { return r.json(); }).then(function (c) {
9
+ if (c.signed_in) { say("you are signed in; opening the app"); location.href = "/"; return; }
10
+ if (!c.firebase || !c.firebase.apiKey) { say("sign-in is not configured on this server yet", true); document.getElementById("google").disabled = true; return; }
11
+ if (typeof firebase === "undefined") { say("could not load the sign-in library", true); return; }
12
+ firebase.initializeApp({ apiKey: c.firebase.apiKey, authDomain: c.firebase.authDomain, projectId: c.firebase.projectId });
13
+ document.getElementById("google").addEventListener("click", function () {
14
+ say("opening Google");
15
+ var provider = new firebase.auth.GoogleAuthProvider();
16
+ firebase.auth().signInWithPopup(provider).then(function (r) { return r.user.getIdToken(true); }).then(function (idToken) {
17
+ tokenBox.value = idToken;
18
+ return fetch("/auth/session", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ id_token: idToken }) })
19
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
20
+ .then(function (x) {
21
+ if (!x.ok) { say(x.j.error || "sign-in refused", true); return; }
22
+ if (document.getElementById("local").open) { say("signed in as " + (x.j.email || x.j.uid) + "; copy the token for your PC, or open the app"); return; }
23
+ location.href = "/";
24
+ });
25
+ }).catch(function (e) { say(e && e.message ? e.message : "sign-in failed", true); });
26
+ });
27
+ }).catch(function () { say("could not reach the server", true); });
28
+ })();
Binary file
Binary file
package/ui/index.html ADDED
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GAF-J</title>
7
+ <link rel="stylesheet" href="/app.css">
8
+ <link rel="manifest" href="/manifest.webmanifest">
9
+ <meta name="theme-color" content="#14171c">
10
+ <meta name="application-name" content="GAF-J">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-title" content="GAF-J">
13
+ <link rel="icon" href="/icon-192.png" type="image/png">
14
+ <link rel="apple-touch-icon" href="/icon-192.png">
15
+ </head>
16
+ <body>
17
+ <div id="app"></div>
18
+ <script type="module" src="/app.js"></script>
19
+ </body>
20
+ </html>
package/ui/lib.js ADDED
@@ -0,0 +1,92 @@
1
+ import { useState, useEffect } from "./vendor/preact.mjs";
2
+
3
+ async function req(method, path, body) {
4
+ const r = await fetch(path, { method, headers: body !== undefined ? { "content-type": "application/json" } : {}, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: "same-origin" });
5
+ const text = await r.text();
6
+ let data;
7
+ try { data = JSON.parse(text); } catch (_) { data = { error: text }; }
8
+ if (r.status === 401) { if (data.hosted) { location.href = "/signin"; } data.error = data.hosted ? "signed out" : "session expired; run gafj url and open the new link"; }
9
+ if (!r.ok) { const e = new Error(data.error || r.statusText); e.status = r.status; e.allowed = data.allowed; e.detail = data.detail; throw e; }
10
+ return data;
11
+ }
12
+ export const api = { get: (p) => req("GET", p), post: (p, b) => req("POST", p, b === undefined ? {} : b), put: (p, b) => req("PUT", p, b === undefined ? {} : b) };
13
+
14
+ export function fmtWhen(iso, tz) {
15
+ if (!iso) return "";
16
+ try {
17
+ return new Intl.DateTimeFormat(undefined, { weekday: "short", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: tz || undefined, timeZoneName: "short" }).format(new Date(iso));
18
+ } catch (_) { return iso; }
19
+ }
20
+
21
+ export function localZone() {
22
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) { return "UTC"; }
23
+ }
24
+
25
+ /** datetime-local value + IANA zone to ISO. */
26
+ export function toIso(local, tz) {
27
+ if (!local) return null;
28
+ const [d, t] = local.split("T");
29
+ const [y, m, day] = d.split("-").map(Number);
30
+ const [hh, mm] = t.split(":").map(Number);
31
+ const guess = Date.UTC(y, m - 1, day, hh, mm);
32
+ const offset = (zoneOffsetMs(guess, tz));
33
+ return new Date(guess - offset).toISOString();
34
+ }
35
+ function zoneOffsetMs(utcMs, tz) {
36
+ try {
37
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" }).formatToParts(new Date(utcMs));
38
+ const g = (t) => Number(parts.find((p) => p.type === t).value);
39
+ return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - utcMs;
40
+ } catch (_) { return 0; }
41
+ }
42
+
43
+ export function useRoute() {
44
+ const parse = () => ({ path: (location.hash.replace(/^#/, "") || "/") });
45
+ const [route, setRoute] = useState(parse);
46
+ useEffect(() => { const on = () => setRoute(parse()); addEventListener("hashchange", on); return () => removeEventListener("hashchange", on); }, []);
47
+ return route;
48
+ }
49
+
50
+ /** A counter that bumps on every change event from the server; screens refetch on it. */
51
+ export function useLive() {
52
+ const [n, setN] = useState(0);
53
+ useEffect(() => {
54
+ let es;
55
+ let timer = null;
56
+ const bump = () => { clearTimeout(timer); timer = setTimeout(() => setN((x) => x + 1), 150); };
57
+ try {
58
+ es = new EventSource("/api/events");
59
+ es.addEventListener("change", bump);
60
+ } catch (_) { /* no SSE */ }
61
+ return () => { if (es) es.close(); };
62
+ }, []);
63
+ return n;
64
+ }
65
+
66
+ export const OPS = [
67
+ { op: "prep", label: "Prep" }, { op: "onepager", label: "One-pager" }, { op: "deep_answers", label: "Deep answers" },
68
+ { op: "practice", label: "Practice env" }, { op: "followup", label: "Thank-you draft" },
69
+ ];
70
+ export const APP_NEXT = { to_apply: ["applied", "pass"], applied: ["rejected", "closed", "offer", "pass"], offer: ["closed", "rejected"], rejected: [], closed: [], pass: [] };
71
+
72
+ /** Light (default) or dark, an explicit choice; the OS preference is not consulted. Persisted client-side only; never sent to the server. */
73
+ const THEME_KEY = "gafj-theme";
74
+ function applyTheme(t) { if (t === "light" || t === "dark") document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme; }
75
+ try { applyTheme(localStorage.getItem(THEME_KEY)); } catch (_) { /* private mode */ }
76
+ export function useTheme() {
77
+ const [theme, setThemeState] = useState(() => { try { return localStorage.getItem(THEME_KEY) || ""; } catch (_) { return ""; } });
78
+ const setTheme = (t) => { setThemeState(t); applyTheme(t); try { if (t) localStorage.setItem(THEME_KEY, t); else localStorage.removeItem(THEME_KEY); } catch (_) { /* private mode */ } };
79
+ const dark = theme === "dark";
80
+ return { theme, dark, toggle: () => setTheme(dark ? "light" : "dark") };
81
+ }
82
+
83
+ /** The browser's install prompt, captured early so the settings screen can offer it. */
84
+ export const installer = { event: null, listeners: new Set(), installed: false };
85
+ addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installer.event = e; for (const l of installer.listeners) l(); });
86
+ addEventListener("appinstalled", () => { installer.installed = true; installer.event = null; for (const l of installer.listeners) l(); });
87
+ export function useInstall() {
88
+ const [, tick] = useState(0);
89
+ useEffect(() => { const l = () => tick((n) => n + 1); installer.listeners.add(l); return () => installer.listeners.delete(l); }, []);
90
+ const standalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
91
+ return { canInstall: !!installer.event, installed: installer.installed || standalone, install: async () => { if (!installer.event) return; installer.event.prompt(); await installer.event.userChoice; installer.event = null; } };
92
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "/",
3
+ "name": "GAF-J",
4
+ "short_name": "GAF-J",
5
+ "description": "Local job campaign engine: one store, one ingest door, your own AI subscription.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "display_override": ["window-controls-overlay", "standalone"],
10
+ "background_color": "#f6f4ef",
11
+ "theme_color": "#14171c",
12
+ "lang": "en",
13
+ "icons": [
14
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
15
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
16
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
17
+ ]
18
+ }
@@ -0,0 +1,73 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api, fmtWhen, toIso, localZone, APP_NEXT } from "../lib.js";
3
+ import { PacketModal } from "./packet.js";
4
+
5
+ export function Application({ id, live }) {
6
+ const [a, setA] = useState(null);
7
+ const [err, setErr] = useState("");
8
+ const [posting, setPosting] = useState(null);
9
+ const [form, setForm] = useState(null);
10
+ const [correct, setCorrect] = useState(null);
11
+ const [note, setNote] = useState(null);
12
+ const [modal, setModal] = useState(null);
13
+ const load = () => api.get(`/api/applications/${id}`).then((x) => { setA(x); setErr(""); }).catch((e) => setErr(e.message));
14
+ useEffect(() => { load(); }, [id, live]);
15
+ const post = async (path, body) => { try { await api.post(path, body); await load(); } catch (e) { setErr(e.message + (e.allowed ? " (allowed: " + e.allowed.join(", ") + ")" : "")); } };
16
+ if (err && !a) return html`<p class="error">${err}</p>`;
17
+ if (!a) return html`<p class="muted">loading</p>`;
18
+ const next = APP_NEXT[a.status] || [];
19
+ const passedResume = a.documents.find((d) => d.kind === "resume" && d.status === "passed");
20
+ const target = { posting_id: a.posting_id, name: a.posting.company };
21
+ return html`<div>
22
+ <h1>${a.posting.company} · ${a.posting.title}</h1>
23
+ <div class="sub"><span class="tag">${a.status}</span> ${a.scoring ? html`fit ${a.scoring.fit_score} (${a.scoring.verdict}, ${a.scoring.rubric_version})` : html`<span class="error">scoring not frozen</span>`}
24
+ ${a.posting.taken_down_at ? html`<span class="tag warn">posting taken down</span>` : ""} ${a.applied_at ? html`<span class="muted">applied ${a.applied_at.slice(0, 10)}</span>` : ""}</div>
25
+ ${err ? html`<p class="error">${err}</p>` : ""}
26
+ <div class="row noprint">
27
+ <button onClick=${async () => setPosting(posting ? null : await api.get(`/api/postings/${a.posting_id}`))}>${posting ? "hide posting" : "view posting"}</button>
28
+ <label>decision by <input type="date" value=${(a.decision_expected_at || "").slice(0, 10)} onChange=${(e) => post(`/api/applications/${id}/decision-expected`, { at: e.target.value ? e.target.value + "T00:00:00Z" : null })} /></label>
29
+ ${next.length ? html`<select onChange=${(e) => { if (e.target.value) { post(`/api/applications/${id}/status`, { status: e.target.value, resume_doc_id: passedResume ? passedResume.document_id : undefined }); e.target.value = ""; } }}>
30
+ <option value="">Status ▾</option>${next.map((s) => html`<option key=${s} value=${s}>${s}</option>`)}</select>` : ""}
31
+ <button onClick=${() => setCorrect(correct ? null : { status: "applied", reason: "" })}>Correct…</button>
32
+ <button onClick=${() => setNote(note === null ? a.notes || "" : null)}>Note</button>
33
+ ${!a.posting.taken_down_at ? html`<button onClick=${() => post(`/api/postings/${a.posting_id}/taken-down`, {})}>Posting taken down</button>` : ""}
34
+ </div>
35
+ ${correct ? html`<form class="inline" onSubmit=${(e) => { e.preventDefault(); post(`/api/applications/${id}/correct`, correct); setCorrect(null); }}>
36
+ <select onChange=${(e) => setCorrect({ ...correct, status: e.target.value })}>${["applied", "to_apply", "offer"].map((s) => html`<option key=${s} value=${s} selected=${correct.status === s}>${s}</option>`)}</select>
37
+ <input required placeholder="reason for the correction" value=${correct.reason} onInput=${(e) => setCorrect({ ...correct, reason: e.target.value })} /><button class="primary">correct</button></form>` : ""}
38
+ ${note !== null ? html`<div><textarea value=${note} onInput=${(e) => setNote(e.target.value)}></textarea><div class="row"><button class="primary" onClick=${() => { post(`/api/applications/${id}/notes`, { notes: note }); setNote(null); }}>save note</button></div></div>` : a.notes ? html`<div class="card intel">${a.notes}</div>` : ""}
39
+ ${posting ? html`<div class="card"><div class="muted small">${[posting.location, posting.comp_text, posting.source_url].filter(Boolean).join(" · ")}</div><pre class="mono" style="white-space:pre-wrap">${posting.raw_text}</pre></div>` : ""}
40
+ <div class="grid2">
41
+ <div>
42
+ <h2>Documents</h2>
43
+ <div class="card"><ul class="list">${a.documents.map((doc) => html`<li key=${doc.document_id}>
44
+ <a href=${"#/documents/" + doc.document_id}>${doc.kind}</a> <span class=${"tag " + doc.status}>${doc.status.toUpperCase()}</span>
45
+ ${a.resume_doc_id === doc.document_id ? html`<span class="tag">submitted</span>` : ""}
46
+ ${doc.status === "blocked" ? html`<span class="small error">${doc.blocks[0] ? doc.blocks[0].rule : ""}</span> ${doc.posting_id ? html`<button class="small noprint" onClick=${async () => setModal({ op: doc.kind, label: "Repair " + doc.kind, doc: await api.get(`/api/documents/${doc.document_id}`) })}>repair</button>` : ""}` : ""}
47
+ <span class="small muted">${doc.route} ${doc.created_at.slice(0, 10)}</span></li>`)}${a.documents.length ? "" : html`<li class="muted">nothing built yet</li>`}</ul>
48
+ <div class="row noprint"><button onClick=${() => setModal({ op: "resume", label: "Resume" })}>Resume</button><button onClick=${() => setModal({ op: "cover", label: "Cover letter" })}>Cover letter</button></div></div>
49
+ <h2>Comms</h2>
50
+ <div class="card"><ul class="list">${a.communications.map((c) => html`<li key=${c.communication_id}>${c.kind} <span class="muted">${c.sent_at.slice(0, 10)}</span> ${c.round_label ? html`<span class="muted">after ${c.round_label}</span>` : ""} ${c.to_addr ? html`<span class="muted">→ ${c.to_addr}</span>` : ""}</li>`)}${a.communications.length ? "" : html`<li class="muted">nothing sent</li>`}</ul></div>
51
+ <h2>People</h2>
52
+ <div class="card"><ul class="list">${a.people.map((p) => html`<li key=${p.person_id + p.role}><b>${p.name}</b> <span class="muted">${[p.title, p.org].filter(Boolean).join(", ")}</span> <span class="tag">${p.role}</span></li>`)}${a.people.length ? "" : html`<li class="muted">no one yet</li>`}</ul></div>
53
+ </div>
54
+ <div>
55
+ <h2>Interviews</h2>
56
+ <div class="card"><ul class="list">${a.interviews.map((i) => html`<li key=${i.interview_id}>
57
+ <a href=${"#/interviews/" + i.interview_id}>${i.round_label} ${i.format}</a> <span class="mono">${fmtWhen(i.scheduled_at, i.tz)}</span> <span class="tag">${i.status}${i.outcome !== "pending" ? " · " + i.outcome : ""}</span>
58
+ <span class="muted small">${i.people.map((p) => p.name).join(", ")}</span></li>`)}${a.interviews.length ? "" : html`<li class="muted">none</li>`}</ul>
59
+ <div class="row noprint"><button onClick=${() => setForm(form ? null : { round_label: "R" + (a.interviews.length + 1), at: "", tz: localZone(), format: "screen", people: "" })}>+ schedule</button></div>
60
+ ${form ? html`<form class="inline" onSubmit=${(e) => { e.preventDefault(); post("/api/interviews", { application_id: id, round_label: form.round_label, scheduled_at: toIso(form.at, form.tz), tz: form.tz, format: form.format, people: form.people.split(",").map((s) => s.trim()).filter(Boolean).map((name) => ({ name })) }); setForm(null); }}>
61
+ <input required value=${form.round_label} onInput=${(e) => setForm({ ...form, round_label: e.target.value })} placeholder="R3" />
62
+ <input type="datetime-local" required value=${form.at} onInput=${(e) => setForm({ ...form, at: e.target.value })} />
63
+ <input value=${form.tz} onInput=${(e) => setForm({ ...form, tz: e.target.value })} title="IANA zone" />
64
+ <select onChange=${(e) => setForm({ ...form, format: e.target.value })}>${["screen", "hm", "panel", "technical", "onsite"].map((f) => html`<option key=${f} value=${f} selected=${form.format === f}>${f}</option>`)}</select>
65
+ <input value=${form.people} onInput=${(e) => setForm({ ...form, people: e.target.value })} placeholder="people, comma separated" />
66
+ <button class="primary">schedule</button></form>` : ""}
67
+ </div>
68
+ ${a.scoring && a.scoring.categories.length ? html`<h2>Scoring</h2><div class="card"><ul class="list">${a.scoring.categories.map((c, i) => html`<li key=${i}><span class="mono">w${c.weight}${c.score != null ? " s" + c.score : ""}</span> ${c.category} ${c.is_hard_gate ? html`<span class="tag warn">hard gate</span>` : ""}</li>`)}</ul><p class="small muted">${a.scoring.verdict_reason || ""}</p></div>` : ""}
69
+ </div>
70
+ </div>
71
+ ${modal ? html`<${PacketModal} op=${modal.op} label=${modal.label} target=${target} document=${modal.doc} onClose=${() => setModal(null)} onSaved=${() => load()} />` : ""}
72
+ </div>`;
73
+ }
@@ -0,0 +1,37 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api, fmtWhen } from "../lib.js";
3
+
4
+ export function Dashboard({ live }) {
5
+ const [snap, setSnap] = useState(null);
6
+ const [msg, setMsg] = useState("");
7
+ const [err, setErr] = useState("");
8
+ useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); }, [live]);
9
+ const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
10
+ if (err && !snap) return html`<p class="error">${err}</p>`;
11
+ if (!snap) return html`<p class="muted">loading</p>`;
12
+ const link = (n) => n.interview_id ? "#/interviews/" + n.interview_id : n.interview_ids ? "#/interviews/" + n.interview_ids[0] : "#/applications";
13
+ return html`<div>
14
+ <h1>Dashboard</h1>
15
+ <div class="sub">${new Date(snap.now).toLocaleString()}</div>
16
+ <h2>Needs you now</h2>
17
+ <div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
18
+ <span class=${"need " + (n.kind === "thankyou_due" || n.kind === "review" ? "soft" : "")}>●</span> <a href=${link(n)}>${n.text}</a></li>`)}</ul>` : html`<span class="muted">nothing waiting on you</span>`}</div>
19
+ <h2>Upcoming</h2>
20
+ <div class="card"><ul class="list">${snap.upcoming.map((u) => html`<li key=${u.interview_id}>
21
+ <span class="mono">${fmtWhen(u.scheduled_at, u.tz)}</span> <a href=${"#/interviews/" + u.interview_id}>${u.company} ${u.round} ${u.format}</a> <span class="muted">${u.people.join(", ")}</span></li>`)}
22
+ ${snap.upcoming.length ? "" : html`<li class="muted">nothing scheduled</li>`}</ul></div>
23
+ <div class="grid2">
24
+ <div><h2>Active (${snap.active.length})</h2><div class="card"><ul class="list">${snap.active.map((a) => html`<li key=${a.application_id}>
25
+ <a href=${"#/applications/" + a.application_id}>${a.company}</a> <span class="muted">${a.title}</span> <span class="tag">${a.status}</span>${a.fit != null ? html`<span class="muted">fit ${a.fit}</span>` : ""}</li>`)}</ul></div></div>
26
+ <div><h2>Closed (${snap.closed.length})</h2><div class="card"><ul class="list">${snap.closed.map((a) => html`<li key=${a.application_id}>
27
+ <a href=${"#/applications/" + a.application_id}>${a.company}</a> <span class="muted">${a.title}</span> <span class="tag">${a.status}</span></li>`)}</ul></div></div>
28
+ </div>
29
+ <h2>Aging</h2>
30
+ <div class="card"><ul class="list">${snap.aging.map((a, i) => html`<li key=${i}><a href=${"#/applications/" + a.application_id}>${a.text}</a></li>`)}${snap.aging.length ? "" : html`<li class="muted">nothing aging</li>`}</ul></div>
31
+ <div class="row noprint">
32
+ <button onClick=${() => act("/api/export-tracker", "tracker written")}>Export tracker snapshot</button>
33
+ <button onClick=${() => act("/api/backup", "backup written")}>Back up</button>
34
+ ${msg ? html`<span class="small mono">${msg}</span>` : ""}${err ? html`<span class="error small">${err}</span>` : ""}
35
+ </div>
36
+ </div>`;
37
+ }