justapply 0.0.0-stage → 0.1.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.
package/index.html ADDED
@@ -0,0 +1,2044 @@
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>JustApply</title>
7
+ <meta name="description" content="See which job applications actually got a reply. Merges Simplify and ApplyTwin exports, on your device only.">
8
+ <meta name="theme-color" content="#0d0d0d">
9
+ <link rel="manifest" href="manifest.webmanifest">
10
+ <link rel="icon" href="icons/icon.svg" type="image/svg+xml">
11
+ <link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
12
+ <meta name="apple-mobile-web-app-capable" content="yes">
13
+ <meta name="mobile-web-app-capable" content="yes">
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15
+ <meta name="apple-mobile-web-app-title" content="JustApply">
16
+ <style>
17
+ /* Calm dark (the original look). Near-black page, cards one tone up, system font.
18
+ Funnel colours from the dataviz reference palette, dark steps, validated all-pairs on #1a1a19:
19
+ blue = applied, aqua = screens/offers, orange = rejections, greys = waiting/ghosted.
20
+ Grid: 12 fluid columns, 16px gaps, 1116px column; 8 cols + 16 + 4 cols closes on both edges. */
21
+ :root {
22
+ color-scheme: dark;
23
+ --ground: #0d0d0d;
24
+ --surface: #1a1a19;
25
+ --raised: #232322;
26
+ --active: #2c2c2a;
27
+ --hover: #212120;
28
+ --border: #2c2c2a;
29
+ --rule: #242423;
30
+ --control: #3a3a38;
31
+ --text: #f0efec;
32
+ --muted: #c3c2b7;
33
+ --faint: #898781;
34
+ --primary: #f0efec;
35
+ --on-primary: #0d0d0d;
36
+ --c-applied: #3987e5;
37
+ --chart-1: #f0efec;
38
+ --chart-3: #199e70; /* aqua: screens and offers */
39
+ --c-rejected: #d95926; /* orange: rejections */
40
+ --c-waiting: #8f8e88;
41
+ --c-ghosted: #4a4946;
42
+ --pill-positive-bg: rgba(25,158,112,0.18); --pill-positive-ink: #5fd3a7;
43
+ --pill-info-bg: rgba(25,158,112,0.18); --pill-info-ink: #5fd3a7;
44
+ --pill-neutral-bg: rgba(217,89,38,0.18); --pill-neutral-ink: #f08a60;
45
+ --heat-0: #232322; --heat-1: #184f95; --heat-2: #256abf; --heat-3: #3987e5; --heat-4: #86b6ef;
46
+ --lift: 0 0 0 1px rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.25);
47
+ --radius-card: 12px; --radius-control: 8px; --radius-chip: 6px;
48
+ --gap: 16px; --pad: 24px; --card-pad: 20px;
49
+ --control-height: 36px; --row-height: 44px;
50
+ --fs-title: 24px; --fs-section: 16px; --fs-kpi: 30px; --fs-body: 14px; --fs-small: 12px;
51
+ --col: 1116px;
52
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
53
+ font-size: var(--fs-body); line-height: 20px;
54
+ }
55
+ * { box-sizing: border-box; }
56
+ html, body { margin: 0; background: var(--ground); color: var(--text); }
57
+ body { min-height: 100vh; -webkit-font-smoothing: antialiased; }
58
+ button, input, select { font: inherit; color: inherit; }
59
+ .num { font-variant-numeric: tabular-nums; }
60
+
61
+ /* Shell: no sidebar. 48px top bar on the ground, centred column. */
62
+ .column { width: min(var(--col), calc(100% - 48px)); margin: 0 auto; }
63
+ .topbar { position: sticky; top: 0; z-index: 5; background: var(--ground); }
64
+ .topbar.scrolled { box-shadow: 0 1px 0 var(--border); }
65
+ .topbar .column { height: 48px; display: flex; align-items: center; gap: 16px; }
66
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 500; font-size: 15px; }
67
+ .mark { width: 22px; height: 22px; flex: none; }
68
+ .scope { color: var(--muted); font-size: 14px; display: flex; align-items: center; gap: 8px; min-width: 0; }
69
+ .scope::before { content: "/"; color: var(--control); }
70
+ .scope span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
71
+ .bar-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
72
+
73
+ /* Buttons: one near-black primary per screen, grey pill secondary, text tertiary. */
74
+ .btn { height: var(--control-height); padding: 0 14px; border-radius: var(--radius-control); border: 1px solid transparent; font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent; transition-property: background-color, color, scale; transition-duration: 150ms; transition-timing-function: ease-out; }
75
+ .btn-primary { background: var(--primary); color: var(--on-primary); }
76
+ .btn-secondary { background: var(--active); color: var(--text); }
77
+ .btn-text { background: none; color: var(--muted); padding: 0 6px; }
78
+ .btn[aria-expanded="true"] { background: var(--text); color: var(--ground); }
79
+ @media (prefers-reduced-motion: no-preference) { .btn:not(:disabled):active, .seg button:not(:disabled):active { scale: 0.96; } }
80
+ @media (hover: hover) {
81
+ .btn-primary:hover { background: #ffffff; }
82
+ .btn-secondary:hover { background: #363634; }
83
+ .btn-text:hover { color: var(--text); }
84
+ .seg button:hover { color: var(--text); }
85
+ .cal-cell:hover { stroke: var(--text); stroke-width: 1.5; }
86
+ .drop:hover { border-color: var(--text); }
87
+ .more:hover { color: var(--text); }
88
+ .row-link:hover { background: var(--hover); }
89
+ }
90
+
91
+ /* Title row */
92
+ main { padding: 28px 0 64px; }
93
+ .title-row { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: flex-end; justify-content: space-between; margin-bottom: 24px; }
94
+ .title-row > div:first-child { flex: 1 1 520px; max-width: 760px; }
95
+ h1 { font-size: 22px; line-height: 28px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
96
+ .finding { font-size: 15px; line-height: 22px; color: var(--muted); margin: 6px 0 0; max-width: 680px; }
97
+ .finding b { color: var(--text); font-weight: 600; }
98
+ .source { font-size: 13px; line-height: 18px; color: var(--faint); margin: 6px 0 0; }
99
+ .seg { display: inline-flex; background: var(--raised); border-radius: var(--radius-control); padding: 3px; height: var(--control-height); }
100
+ .seg button { border: 0; background: none; padding: 0 10px; border-radius: 5px; /* 8 outer - 3 padding */ color: var(--muted); font-size: 13px; cursor: pointer; white-space: nowrap; transition-property: color, background-color, scale; transition-duration: 150ms; transition-timing-function: ease-out; -webkit-tap-highlight-color: transparent; }
101
+ .seg { position: relative; }
102
+ .seg button { position: relative; z-index: 1; }
103
+ .seg button[aria-pressed="true"] { color: var(--text); font-weight: 500; }
104
+ /* The selected pill slides to the chosen option; a CSS transition retargets from its current position. */
105
+ .seg-thumb { position: absolute; top: 3px; bottom: 3px; left: 3px; width: 0; border-radius: 5px; background: var(--active); transition: transform 260ms cubic-bezier(0.2, 0, 0, 1), width 260ms cubic-bezier(0.2, 0, 0, 1); }
106
+ @media (prefers-reduced-motion: reduce) { .seg-thumb { transition: none; } }
107
+ .notice { font-size: 13px; color: var(--muted); background: var(--surface); border: 1px solid transparent; box-shadow: var(--lift); border-radius: var(--radius-control); padding: 8px 12px; margin: 0 0 var(--gap); }
108
+
109
+ /* Grid and panels */
110
+ .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
111
+ .span-12 { grid-column: span 12; } .span-8 { grid-column: span 8; } .span-4 { grid-column: span 4; }
112
+ @media (max-width: 960px) { .span-8, .span-4 { grid-column: span 12; } }
113
+ .panel { background: var(--surface); border: 1px solid transparent; box-shadow: var(--lift); border-radius: var(--radius-card); padding: 24px; min-width: 0; display: flex; flex-direction: column; }
114
+ .panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
115
+ .panel-head h2 { font-size: var(--fs-section); line-height: 24px; font-weight: 500; margin: 0; }
116
+ .panel-head .meta { font-size: 13px; color: var(--faint); }
117
+ .note { font-size: 13px; line-height: 19px; color: var(--faint); margin: 12px 0 0; }
118
+
119
+ /* Figures strip inside the pipeline panel: one container split by 1px dividers. */
120
+ .figures { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; align-items: end; margin: 0 0 24px; }
121
+ .fig { padding: 0 16px; border-left: 1px solid var(--border); }
122
+ .fig:first-child { padding-left: 0; border-left: 0; }
123
+ .fig-label { font-size: 13px; color: var(--muted); }
124
+ .fig-value { font-size: 24px; line-height: 30px; font-weight: 500; letter-spacing: -0.01em; margin-top: 2px; font-variant-numeric: tabular-nums; }
125
+ .fig-value small { font-size: 13px; color: var(--faint); margin-left: 4px; }
126
+ .fig.lead .fig-value { font-size: 34px; line-height: 40px; font-weight: 600; letter-spacing: -0.02em; }
127
+ .fig.lead .fig-value small { color: var(--muted); letter-spacing: 0; }
128
+ @media (max-width: 640px) {
129
+ .figures { grid-template-columns: 1fr 1fr; row-gap: 16px; }
130
+ .fig:nth-child(3) { padding-left: 0; border-left: 0; }
131
+ }
132
+
133
+ /* Sankey */
134
+ #sankey svg { display: block; overflow: visible; }
135
+ .s-link { fill: none; transition: stroke-opacity 150ms ease-out; }
136
+ .s-label { font-size: 13px; fill: var(--muted); paint-order: stroke; stroke: var(--surface); stroke-width: 4px; stroke-linejoin: round; }
137
+ .s-label tspan.n { fill: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
138
+ .s-label tspan.p { fill: var(--faint); font-variant-numeric: tabular-nums; }
139
+
140
+ /* Lists */
141
+ .list { list-style: none; margin: 0; padding: 0; }
142
+ .list li { display: flex; align-items: center; gap: 12px; min-height: var(--row-height); padding: 6px 0; border-top: 1px solid var(--rule); }
143
+ .list li:first-child { border-top: 0; }
144
+ .li-main { flex: 1; min-width: 0; }
145
+ /* Rows that open the posting: the hover band bleeds 8px past the text edge, the text stays on it. */
146
+ .row-link { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; align-self: stretch; margin: -6px -8px; padding: 6px 8px; border-radius: var(--radius-control); color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; transition: background-color 150ms ease-out; }
147
+ .row-link:active { background: var(--active); transition-duration: 0ms; }
148
+ .li-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
149
+ .li-sub { font-size: 13px; line-height: 18px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
150
+ .li-end { text-align: right; flex: none; }
151
+ .li-date { font-size: 13px; color: var(--faint); margin-top: 2px; font-variant-numeric: tabular-nums; }
152
+ .pill { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: var(--radius-chip); font-size: 12px; font-weight: 500; white-space: nowrap; flex: none; }
153
+ .pill-info { background: var(--pill-info-bg); color: var(--pill-info-ink); }
154
+ .pill-positive { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
155
+ .pill-neutral { background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); }
156
+ .more { margin-top: auto; padding-top: 12px; background: none; border: 0; -webkit-tap-highlight-color: transparent; color: var(--muted); font-size: 13px; font-weight: 500; cursor: pointer; text-align: left; transition: color 150ms ease-out; }
157
+ .empty-line { border: 1px dashed var(--control); border-radius: var(--radius-card); padding: 10px 12px; color: var(--muted); font-size: 13px; }
158
+
159
+ /* Waiting clock: bins of days waiting, against the 30-day ghost line */
160
+ .clock { display: grid; gap: 10px; }
161
+ .clock-row { display: grid; grid-template-columns: 84px 1fr 34px; align-items: center; gap: 10px; font-size: 13px; }
162
+ .clock-row .lbl { color: var(--muted); white-space: nowrap; }
163
+ .clock-row .val { text-align: right; font-variant-numeric: tabular-nums; }
164
+ .track { position: relative; height: 8px; }
165
+ .track b { position: absolute; inset: 0; border-radius: 0 2px 2px 0; background: var(--c-waiting); transform-origin: left center; transform: scaleX(0); transition: background-color 150ms ease-out; }
166
+ @media (prefers-reduced-motion: reduce) { .track b { transition: none; } }
167
+ .clock-row { border-radius: 4px; outline-offset: 4px; }
168
+ @media (hover: hover) { .clock-row:hover .track b { background: var(--c-ghosted); } .clock-row.soon:hover .track b, .clock-row.ghost:hover .track b { background: var(--chart-3); } }
169
+ .clock-row.soon .track b { background: var(--chart-1); }
170
+ .track { background: var(--raised); border-radius: 2px; }
171
+ .clock-row.ghost .track b { background: var(--c-ghosted); }
172
+ .clock-row.soon .lbl, .clock-row.soon .val { color: var(--text); font-weight: 500; }
173
+
174
+ .sub-head { font-size: 13px; font-weight: 500; color: var(--muted); margin: 20px 0 2px; }
175
+ .days { font-variant-numeric: tabular-nums; color: var(--text); }
176
+
177
+ /* Calendar */
178
+ .cal-scroll { overflow-x: auto; }
179
+ #calendar svg { display: block; }
180
+ .cal-cell { rx: 2; }
181
+ .cal-cell:focus-visible { stroke: var(--text); stroke-width: 1.5; outline: none; }
182
+ .cal-axis { font-size: 11px; fill: var(--faint); }
183
+ .cal-legend { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--faint); }
184
+ .cal-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
185
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
186
+ .stat { padding: 0 16px; border-left: 1px solid var(--border); }
187
+ .stat:first-child { padding-left: 0; border-left: 0; }
188
+ .stat-v { font-size: 22px; line-height: 28px; font-weight: 500; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
189
+ .stat-l { font-size: 13px; color: var(--faint); }
190
+ @media (max-width: 640px) { .stats { grid-template-columns: 1fr 1fr; row-gap: 14px; } .stat:nth-child(3) { padding-left: 0; border-left: 0; } }
191
+
192
+ /* What's new */
193
+ .new-sec + .new-sec { margin-top: 16px; }
194
+ .new-sec h3 { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 4px; }
195
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
196
+ .change { font-size: 13px; color: var(--faint); white-space: nowrap; }
197
+
198
+ /* Editor */
199
+ .editor input[type=search] { width: 100%; height: 38px; background: var(--surface); border: 1px solid var(--control); border-radius: var(--radius-control); padding: 0 12px; outline: none; }
200
+ .editor input[type=search]:focus { border-color: var(--text); }
201
+ .editor select { height: 32px; background: var(--surface); border: 1px solid var(--control); border-radius: var(--radius-control); padding: 0 8px; font-size: 13px; }
202
+ .tag { font-size: 12px; color: #9cc6f5; background: rgba(57,135,229,0.18); border-radius: var(--radius-chip); padding: 1px 6px; margin-left: 8px; font-weight: 500; }
203
+
204
+ /* Tooltip floats, so it may carry a shadow */
205
+ #tip { position: fixed; pointer-events: none; z-index: 10; background: var(--raised); border: 1px solid transparent; border-radius: var(--radius-control); padding: 6px 10px; font-size: 13px; color: var(--muted); box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 2px 4px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.45); opacity: 0; transition: opacity 100ms ease-out; max-width: 260px; }
206
+ #tip .v { color: var(--text); font-weight: 500; font-size: 13px; font-variant-numeric: tabular-nums; }
207
+ #tip .k { display: inline-block; width: 10px; height: 2px; vertical-align: middle; margin-right: 6px; }
208
+
209
+ /* First run */
210
+ .drop { border: 1px dashed var(--control); border-radius: var(--radius-card); background: var(--surface); padding: 56px 24px; text-align: center; cursor: pointer; transition: border-color 150ms ease-out; }
211
+ .drop.over { border-color: var(--c-applied); }
212
+ .drop h2 { font-size: var(--fs-section); font-weight: 500; margin: 0 0 6px; }
213
+ .drop p { color: var(--muted); margin: 0 auto 18px; max-width: 520px; }
214
+ .error { color: var(--negative); font-size: 13px; margin-top: 12px; }
215
+ #overlay { position: fixed; inset: 0; background: rgba(13,13,13,0.88); border: 2px dashed var(--c-applied); color: var(--text); display: none; align-items: center; justify-content: center; font-size: 16px; font-weight: 500; z-index: 20; }
216
+ #overlay.on { display: flex; }
217
+
218
+ @media (max-width: 640px) {
219
+ .column { width: calc(100% - 32px); }
220
+ .scope { display: none; }
221
+ main { padding-top: 20px; }
222
+ h1 { font-size: 22px; }
223
+ .title-row { align-items: stretch; flex-direction: column; }
224
+ .title-row > div:first-child { flex: none; }
225
+ .fig.lead .fig-value { font-size: 30px; line-height: 36px; }
226
+ .seg { width: 100%; } .seg button { flex: 1; padding: 0 4px; }
227
+ .btn { height: 40px; }
228
+ .bar-actions .btn-text { display: none; }
229
+ .topbar .column { gap: 10px; }
230
+ .brand { font-size: 14px; }
231
+ .bar-actions .btn { padding: 0 10px; font-size: 13px; }
232
+ .brand-name { display: none; }
233
+ .bar-actions { gap: 6px; }
234
+ .btn-menu { padding: 0 8px 0 10px; }
235
+ }
236
+ :where(button, a, input, select, [tabindex]):focus-visible { outline: 2px solid var(--c-applied); outline-offset: 2px; }
237
+ .s-link:focus-visible, .hit:focus-visible { outline: none; }
238
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
239
+
240
+ /* Icon buttons and the dropdown chevron */
241
+ .btn-icon { width: var(--control-height); padding: 0; display: inline-grid; place-items: center; }
242
+ .btn-icon svg { width: 16px; height: 16px; }
243
+ .btn-menu { display: inline-flex; align-items: center; gap: 6px; padding: 0 10px 0 12px; background: none; color: var(--muted); } /* 2px less on the icon side */
244
+ .btn-menu[aria-expanded="true"] { background: var(--raised); color: var(--text); }
245
+ @media (hover: hover) { .btn-menu:hover { background: var(--raised); color: var(--text); } }
246
+ .chev { width: 14px; height: 14px; color: var(--faint); transition: rotate 150ms ease-out; }
247
+ .btn-menu[aria-expanded="true"] .chev { rotate: 180deg; }
248
+
249
+ /* Menus: float, so they carry a shadow; they scale from the corner of their trigger. */
250
+ .menu-wrap { position: relative; }
251
+ .menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 200px; padding: 4px; background: var(--raised); border: 1px solid transparent; border-radius: 10px; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 4px 8px rgba(0,0,0,0.3), 0 12px 32px rgba(0,0,0,0.45); transform-origin: top right; overscroll-behavior: contain; }
252
+ .menu-item { display: flex; align-items: center; gap: 10px; width: 100%; height: 34px; padding: 0 10px; border: 0; background: none; border-radius: 6px; /* 10 outer - 4 padding */ color: var(--text); font-size: 14px; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color 100ms ease-out; }
253
+ .menu-item .check { width: 14px; height: 14px; color: var(--c-applied); flex: none; visibility: hidden; }
254
+ .menu-item[aria-checked="true"] .check { visibility: visible; }
255
+ .menu-item.danger { color: #f08a60; }
256
+ .menu-item:focus-visible { outline: none; background: var(--active); }
257
+ .menu-sep { height: 1px; background: var(--border); margin: 4px 6px; }
258
+ .menu-meta { font-size: 12px; color: var(--faint); padding: 6px 10px 4px; line-height: 16px; }
259
+ @media (hover: hover) { .menu-item:hover { background: var(--active); } }
260
+
261
+ /* Funnel header: the drawer's openers */
262
+ .head-actions { display: flex; gap: 8px; }
263
+ .chip-btn { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid transparent; background: var(--raised); color: var(--muted); font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; -webkit-tap-highlight-color: transparent; transition-property: background-color, color, scale; transition-duration: 150ms; transition-timing-function: ease-out; }
264
+ .chip-btn b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
265
+ .chip-btn .dot { width: 7px; height: 7px; }
266
+ .chip-btn[aria-expanded="true"] { background: var(--active); color: var(--text); }
267
+ @media (hover: hover) { .chip-btn:hover { background: var(--active); color: var(--text); } }
268
+ @media (prefers-reduced-motion: no-preference) { .chip-btn:active { scale: 0.96; } }
269
+
270
+ /* Applying: calendar beside its four figures */
271
+ .applying { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; align-items: center; }
272
+ .applying .stats { grid-template-columns: 1fr 1fr; row-gap: 20px; margin: 0; padding: 0 0 0 32px; border-top: 0; border-left: 1px solid var(--border); }
273
+ .applying .stat:nth-child(3) { padding-left: 0; border-left: 0; }
274
+ @media (max-width: 860px) {
275
+ .applying { grid-template-columns: 1fr; gap: 20px; }
276
+ .applying .stats { padding: 20px 0 0; border-left: 0; border-top: 1px solid var(--border); }
277
+ }
278
+
279
+ /* Drawer */
280
+ .drawer { position: fixed; top: 8px; right: 8px; bottom: 8px; z-index: 25; width: min(420px, calc(100vw - 16px)); display: flex; flex-direction: column; background: var(--surface); border: 1px solid transparent; border-radius: 16px; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 8px 16px rgba(0,0,0,0.35), 0 24px 64px rgba(0,0,0,0.5); }
281
+ .drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--border); }
282
+ .tabs { display: flex; gap: 4px; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
283
+ .tab { height: 32px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--faint); font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent; transition-property: background-color, color; transition-duration: 150ms; transition-timing-function: ease-out; }
284
+ .tab[aria-selected="true"] { background: var(--raised); color: var(--text); }
285
+ @media (hover: hover) { .tab:hover { color: var(--text); } }
286
+ .drawer-body { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px 20px 24px; }
287
+ .pane { display: flex; flex-direction: column; }
288
+ .pane-meta { font-size: 13px; color: var(--faint); margin: 0 0 12px; line-height: 19px; }
289
+ @media (max-width: 640px) { .drawer { top: auto; left: 8px; right: 8px; width: auto; height: min(78vh, 640px); } }
290
+
291
+
292
+ /* Sections: Applications | OAs */
293
+ .views { display: flex; gap: 2px; margin-left: 8px; }
294
+ .view-link { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; border-radius: 8px; color: var(--faint); font-size: 14px; font-weight: 500; text-decoration: none; -webkit-tap-highlight-color: transparent; transition-property: background-color, color; transition-duration: 150ms; transition-timing-function: ease-out; }
295
+ .view-link[aria-current="page"] { background: var(--raised); color: var(--text); }
296
+ @media (hover: hover) { .view-link:hover { color: var(--text); } }
297
+ .span-5 { grid-column: span 5; } .span-7 { grid-column: span 7; }
298
+ @media (max-width: 960px) { .span-5, .span-7 { grid-column: span 12; } }
299
+
300
+ /* Weak spots */
301
+ .weak { display: grid; gap: 12px; }
302
+ .weak-row { display: grid; grid-template-columns: minmax(0, 120px) 1fr auto; align-items: center; gap: 12px; font-size: 13px; }
303
+ .weak-row .lbl { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
304
+ .weak-row .val { color: var(--faint); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
305
+ .stack { display: flex; gap: 2px; height: 8px; }
306
+ .stack i { display: block; height: 100%; border-radius: 2px; }
307
+ .k-solved { background: var(--chart-3); } .k-partial { background: var(--c-waiting); } .k-missed { background: var(--c-rejected); }
308
+ .legend { display: flex; gap: 14px; font-size: 12px; color: var(--faint); }
309
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
310
+ .legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
311
+ .digest { list-style: none; margin: 0; padding: 0; }
312
+ .digest li { padding: 10px 0; border-top: 1px solid var(--rule); font-size: 14px; line-height: 20px; }
313
+ .digest li:first-child { border-top: 0; }
314
+ .digest .from { font-size: 12px; color: var(--faint); margin-top: 2px; }
315
+
316
+ /* OA list */
317
+ .oa-row { display: flex; align-items: center; gap: 12px; width: 100%; margin: -6px -8px; padding: 8px; border: 0; background: none; border-radius: var(--radius-control); color: inherit; text-align: left; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color 150ms ease-out; width: calc(100% + 16px); }
318
+ .oa-row:active { background: var(--active); transition-duration: 0ms; }
319
+ @media (hover: hover) { .oa-row:hover { background: var(--hover); } }
320
+ .qdots { display: inline-flex; gap: 4px; vertical-align: middle; margin-left: 2px; }
321
+ .qdots i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
322
+
323
+ /* OA form (inside the side panel) */
324
+ .drawer-title { font-size: 16px; font-weight: 600; margin: 0; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
325
+ .saved { font-size: 12px; color: var(--faint); white-space: nowrap; opacity: 0; transition: opacity 200ms ease-out; }
326
+ .saved.on { opacity: 1; }
327
+ .form { display: grid; gap: 16px; }
328
+ .form .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
329
+ .field { display: grid; gap: 6px; min-width: 0; }
330
+ .field > span { font-size: 13px; font-weight: 500; color: var(--muted); }
331
+ .field input, .field select, .field textarea, .q-row input, .q-row select { width: 100%; min-width: 0; height: 38px; background: var(--ground); border: 1px solid var(--control); border-radius: var(--radius-control); padding: 0 10px; color: var(--text); font: inherit; font-size: 14px; outline: none; transition: border-color 150ms ease-out; }
332
+ .field textarea { height: auto; min-height: 76px; padding: 8px 10px; line-height: 20px; resize: vertical; }
333
+ .field input:focus, .field select:focus, .field textarea:focus, .q-row input:focus, .q-row select:focus { border-color: var(--c-applied); }
334
+ .field input[type=date] { color-scheme: dark; }
335
+ .form-sub { font-size: 13px; font-weight: 600; color: var(--text); margin: 8px 0 -6px; }
336
+ .q-list { display: grid; gap: 8px; }
337
+ /* One question: topic, result and remove on the first line; the optional name under them. */
338
+ .q-row { display: grid; grid-template-columns: 1fr 128px 32px; gap: 8px; align-items: center; padding: 10px; background: var(--raised); border-radius: 10px; }
339
+ .q-row [data-k=name] { grid-column: 1 / 3; grid-row: 2; }
340
+ .q-row .btn-icon { width: 32px; height: 32px; }
341
+ @media (max-width: 640px) { .form .two { grid-template-columns: 1fr; } }
342
+ #oaGrid { align-items: start; }
343
+ .form-foot { display: flex; justify-content: space-between; align-items: center; padding-top: 8px; border-top: 1px solid var(--border); }
344
+ .btn-danger { background: none; color: #f08a60; padding: 0 6px; }
345
+ @media (hover: hover) { .btn-danger:hover { color: #ffab8a; } }
346
+
347
+ .file-notice { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 var(--gap); padding: 10px 12px 10px 16px; }
348
+ .file-notice .btn { height: 32px; flex: none; }
349
+ .menu { min-width: 260px; }
350
+ .menu-meta { max-width: 280px; }
351
+
352
+ /* Scrollbars: thin, dark, no track, so they sit quietly in the dark theme. */
353
+ * { scrollbar-width: thin; scrollbar-color: #3a3a38 transparent; }
354
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
355
+ ::-webkit-scrollbar-track { background: transparent; }
356
+ ::-webkit-scrollbar-thumb { background: #3a3a38; border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
357
+ ::-webkit-scrollbar-thumb:hover { background-color: #4a4946; }
358
+
359
+ /* Day popover from the calendar: floats, so it carries a shadow; scrolls inside itself. */
360
+ .cal-cell.has-apps { cursor: pointer; }
361
+ .cal-cell.picked { stroke: var(--text); stroke-width: 2; }
362
+ .day-pop { position: absolute; z-index: 28; width: 340px; max-width: calc(100vw - 16px); max-height: min(420px, calc(100vh - 32px)); display: flex; flex-direction: column; background: var(--raised); border: 1px solid transparent; border-radius: 14px; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 4px 8px rgba(0,0,0,0.3), 0 16px 40px rgba(0,0,0,0.5); }
363
+ .day-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 14px 10px 10px 16px; border-bottom: 1px solid var(--border); }
364
+ .day-title { font-size: 15px; font-weight: 600; }
365
+ .day-count { font-size: 13px; color: var(--faint); margin-top: 2px; }
366
+ .day-list { overflow-y: auto; overscroll-behavior: contain; padding: 4px 16px 8px; }
367
+ .day-list li { min-height: 52px; }
368
+ .pill-muted { background: var(--active); color: var(--muted); }
369
+
370
+ [hidden] { display: none !important; }
371
+ </style>
372
+ </head>
373
+ <body>
374
+ <div class="topbar" id="topbar">
375
+ <div class="column">
376
+ <div class="brand">
377
+ <svg class="mark" viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" rx="112" fill="#f0efec"/><path d="M118 250l276-118-82 262-64-90z" fill="#141414"/><path d="M248 304l146-172-180 146z" fill="#3987e5"/><path d="M248 304l-4 82 50-52z" fill="#5b9bea"/></svg>
378
+ <span class="brand-name">JustApply</span>
379
+ </div>
380
+ <nav class="views" aria-label="Sections">
381
+ <a class="view-link" id="navApps" href="#apps" aria-current="page">Applications</a>
382
+ <a class="view-link" id="navOas" href="#oas">OAs</a>
383
+ </nav>
384
+ <div class="scope" id="scope"><span>No files loaded</span></div>
385
+ <div class="bar-actions">
386
+ <div class="menu-wrap">
387
+ <button class="btn btn-menu" id="rangeBtn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="rangeMenu" hidden>
388
+ <span id="rangeLabel">All time</span>
389
+ <svg class="chev" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
390
+ </button>
391
+ <div class="menu" id="rangeMenu" role="menu" aria-label="Date range" hidden></div>
392
+ </div>
393
+ <button class="btn btn-primary" id="newOaBtn" type="button" hidden>New OA</button>
394
+ <button class="btn btn-primary" id="addBtn" type="button">Load export</button>
395
+ <div class="menu-wrap">
396
+ <button class="btn btn-secondary btn-icon" id="moreBtn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="moreMenu" aria-label="More">
397
+ <svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3.5" cy="8" r="1.4" fill="currentColor"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/><circle cx="12.5" cy="8" r="1.4" fill="currentColor"/></svg>
398
+ </button>
399
+ <div class="menu" id="moreMenu" role="menu" aria-label="More" hidden>
400
+ <button class="menu-item" role="menuitem" type="button" id="editBtn">Edit statuses</button>
401
+ <div class="menu-sep" role="separator"></div>
402
+ <button class="menu-item" role="menuitem" type="button" id="csvBtn">Download applications (CSV)</button>
403
+ <button class="menu-item" role="menuitem" type="button" id="backupBtn">Download backup</button>
404
+ <button class="menu-item" role="menuitem" type="button" id="restoreBtn">Restore from backup…</button>
405
+ <div class="menu-sep" role="separator"></div>
406
+ <div class="menu-meta" id="fileMeta"></div>
407
+ <button class="menu-item" role="menuitem" type="button" id="autoSaveBtn">Auto-save to a file…</button>
408
+ <button class="menu-item" role="menuitem" type="button" id="openFileBtn">Open data file…</button>
409
+ <button class="menu-item" role="menuitem" type="button" id="stopFileBtn" hidden>Stop auto-saving</button>
410
+ <div class="menu-sep" role="separator"></div>
411
+ <div class="menu-meta" id="historyMeta"></div>
412
+ <button class="menu-item danger" role="menuitem" type="button" id="clearBtn">Delete all data…</button>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </div>
418
+
419
+ <main class="column">
420
+ <h1 class="sr-only">JustApply</h1>
421
+
422
+ <div class="notice file-notice" id="fileNotice" hidden><span id="fileNoticeText"></span><button class="btn btn-secondary" id="fileNoticeBtn" type="button">Reconnect</button></div>
423
+ <div id="appsView">
424
+ <section id="empty">
425
+ <div class="drop" id="dropzone" tabindex="0" role="button" aria-label="Choose CSV files">
426
+ <h2>Drop your tracker exports here</h2>
427
+ <p>Simplify and ApplyTwin CSVs are recognized automatically, and every export you load is kept, so your history builds up over time. Nothing leaves your device.</p>
428
+ <button class="btn btn-primary" type="button">Choose files</button>
429
+ <div class="error" id="error" hidden></div>
430
+ </div>
431
+ </section>
432
+
433
+ <section id="dash" hidden>
434
+ <div class="notice" id="rangeNote" hidden></div>
435
+
436
+ <div class="grid">
437
+ <section class="panel span-12" id="whatsNew" hidden>
438
+ <div class="panel-head"><h2 id="newTitle">What's new</h2><button class="btn btn-text" id="newDismiss" type="button">Dismiss</button></div>
439
+ <p class="note" id="newSub" style="margin:-10px 0 12px"></p>
440
+ <div id="newBody"></div>
441
+ </section>
442
+
443
+ <section class="panel span-12">
444
+ <div class="panel-head">
445
+ <h2>Funnel</h2>
446
+ <div class="head-actions">
447
+ <button class="chip-btn" id="openReplies" type="button"></button>
448
+ <button class="chip-btn" id="openWaiting" type="button"></button>
449
+ </div>
450
+ </div>
451
+ <div class="figures" id="figures"></div>
452
+ <div id="sankey"></div>
453
+ <p class="note" id="sankeyNote"></p>
454
+ </section>
455
+
456
+ <section class="panel span-12">
457
+ <div class="panel-head"><h2>Applying</h2>
458
+ <div class="cal-legend" aria-hidden="true">Fewer <i style="background:var(--heat-0)"></i><i style="background:var(--heat-1)"></i><i style="background:var(--heat-2)"></i><i style="background:var(--heat-3)"></i><i style="background:var(--heat-4)"></i> More</div>
459
+ </div>
460
+ <div class="applying">
461
+ <div class="cal-scroll"><div id="calendar"></div></div>
462
+ <div class="stats" id="calStats"></div>
463
+ </div>
464
+ <p class="note" id="calNote"></p>
465
+ </section>
466
+ </div>
467
+ </section>
468
+ </div>
469
+ <div id="oaView" hidden>
470
+ <section class="panel" id="oaEmpty" hidden>
471
+ <div class="panel-head"><h2>Online assessments</h2></div>
472
+ <p class="pane-meta" style="max-width:560px">Log each OA you take: the questions, their topics and how each went, plus what went well, what went wrong and what to do next time. Topics you struggle with show up here as weak spots.</p>
473
+ <div><button class="btn btn-primary" type="button" data-new-oa>Log your first OA</button></div>
474
+ </section>
475
+ <div class="grid" id="oaGrid" hidden>
476
+ <section class="panel span-5">
477
+ <div class="panel-head"><h2>Weak spots</h2>
478
+ <div class="legend" aria-hidden="true"><span><i class="k-solved"></i>Solved</span><span><i class="k-partial"></i>Partial</span><span><i class="k-missed"></i>Not solved</span></div>
479
+ </div>
480
+ <div class="weak" id="weak"></div>
481
+ <h3 class="sub-head">Next time</h3>
482
+ <ul class="digest" id="digest"></ul>
483
+ </section>
484
+ <section class="panel span-7">
485
+ <div class="panel-head"><h2>OAs</h2><span class="meta" id="oaMeta"></span></div>
486
+ <ul class="list" id="oaList"></ul>
487
+ </section>
488
+ </div>
489
+ </div>
490
+ </main>
491
+
492
+ <!-- Tucked-away details: one drawer, three tabs. Non-blocking, so no scrim; Esc or the close button closes it. -->
493
+ <aside class="drawer" id="drawer" aria-label="Details" hidden>
494
+ <div class="drawer-head">
495
+ <div class="tabs" role="tablist" aria-label="Details">
496
+ <button class="tab" role="tab" id="tab-replies" aria-controls="pane-replies" aria-selected="true" type="button">Heard back</button>
497
+ <button class="tab" role="tab" id="tab-waiting" aria-controls="pane-waiting" aria-selected="false" type="button">Waiting</button>
498
+ <button class="tab" role="tab" id="tab-edit" aria-controls="pane-edit" aria-selected="false" type="button">Edit statuses</button>
499
+ </div>
500
+ <button class="btn btn-text btn-icon" id="drawerClose" type="button" aria-label="Close details">
501
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>
502
+ </button>
503
+ </div>
504
+ <div class="drawer-body">
505
+ <section class="pane" id="pane-replies" role="tabpanel" aria-labelledby="tab-replies">
506
+ <p class="pane-meta" id="heardMeta"></p>
507
+ <ul class="list" id="heard"></ul>
508
+ <button class="more" id="heardMore" type="button" hidden></button>
509
+ </section>
510
+ <section class="pane" id="pane-waiting" role="tabpanel" aria-labelledby="tab-waiting" hidden>
511
+ <p class="pane-meta">Ghosted after 30 days with no reply.</p>
512
+ <div class="clock" id="clock"></div>
513
+ <p class="note" id="clockNote"></p>
514
+ <h3 class="sub-head">Waited longest</h3>
515
+ <ul class="list" id="oldest"></ul>
516
+ </section>
517
+ <section class="pane editor" id="pane-edit" role="tabpanel" aria-labelledby="tab-edit" hidden>
518
+ <p class="pane-meta">For updates your tracker doesn't have yet. An edit is dropped once your CSV shows a newer status for that job.</p>
519
+ <input type="search" id="edSearch" placeholder="Search company or role" autocomplete="off" aria-label="Search company or role">
520
+ <ul class="list" id="edList" style="margin-top:8px"></ul>
521
+ </section>
522
+ </div>
523
+ </aside>
524
+
525
+ <aside class="drawer" id="oaPanel" aria-labelledby="oaTitle" hidden>
526
+ <div class="drawer-head">
527
+ <h2 class="drawer-title" id="oaTitle">New OA</h2>
528
+ <span class="saved" id="oaSaved" aria-live="polite"></span>
529
+ <button class="btn btn-text btn-icon" id="oaClose" type="button" aria-label="Close OA">
530
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>
531
+ </button>
532
+ </div>
533
+ <div class="drawer-body">
534
+ <form class="form" id="oaForm" autocomplete="off" onsubmit="return false">
535
+ <div class="two">
536
+ <label class="field"><span>Company</span><input name="company" list="companyList" required></label>
537
+ <label class="field"><span>Role</span><input name="role" placeholder="Optional"></label>
538
+ </div>
539
+ <div class="two">
540
+ <label class="field"><span>Date</span><input name="date" type="date"></label>
541
+ <label class="field"><span>Platform</span><input name="platform" list="platformList" placeholder="HackerRank, CodeSignal…"></label>
542
+ </div>
543
+ <label class="field"><span>Score</span><input name="score" placeholder="e.g. 3/4 questions, or 520/600"></label>
544
+ <p class="form-sub">Questions</p>
545
+ <div class="q-list" id="qList"></div>
546
+ <div><button class="btn btn-secondary" id="addQ" type="button">Add question</button></div>
547
+ <p class="form-sub">Notes</p>
548
+ <label class="field"><span>Went well</span><textarea name="well" rows="3"></textarea></label>
549
+ <label class="field"><span>Went wrong</span><textarea name="wrong" rows="3"></textarea></label>
550
+ <label class="field"><span>Next time</span><textarea name="next" rows="3"></textarea></label>
551
+ <div class="form-foot">
552
+ <button class="btn btn-danger" id="oaDelete" type="button">Delete this OA</button>
553
+ <button class="btn btn-primary" id="oaDone" type="button">Done</button>
554
+ </div>
555
+ </form>
556
+ </div>
557
+ </aside>
558
+ <datalist id="companyList"></datalist>
559
+ <datalist id="platformList"><option>HackerRank</option><option>CodeSignal</option><option>Codility</option><option>LeetCode</option><option>CoderPad</option><option>HackerEarth</option><option>Karat</option><option>Other</option></datalist>
560
+ <datalist id="topicList"></datalist>
561
+
562
+ <input type="file" id="fileInput" accept=".csv,text/csv" multiple hidden>
563
+ <input type="file" id="restoreInput" accept=".json,application/json" hidden>
564
+ <div class="day-pop" id="dayPop" role="dialog" aria-labelledby="dayTitle" hidden>
565
+ <div class="day-head">
566
+ <div><div class="day-title" id="dayTitle"></div><div class="day-count" id="dayCount"></div></div>
567
+ <button class="btn btn-text btn-icon" id="dayClose" type="button" aria-label="Close">
568
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>
569
+ </button>
570
+ </div>
571
+ <ul class="list day-list" id="dayList"></ul>
572
+ </div>
573
+ <div id="tip" role="tooltip"></div>
574
+ <div id="overlay">Drop CSV to load</div>
575
+
576
+ <script src="vendor/papaparse.min.js"></script>
577
+ <script src="vendor/d3.min.js"></script>
578
+ <script src="vendor/d3-sankey.min.js"></script>
579
+ <script>
580
+ (() => {
581
+ 'use strict';
582
+
583
+ const GHOST_DAYS = 30;
584
+ const motionQuery = matchMedia('(prefers-reduced-motion: no-preference)');
585
+ let MOTION = motionQuery.matches;
586
+ motionQuery.addEventListener?.('change', e => { MOTION = e.matches; });
587
+ // Fade/rise for panels that open in place. Interruptible: a new call cancels the running one and starts
588
+ // from the panel's current opacity, so opening and closing quickly never jumps.
589
+ const EASE = 'cubic-bezier(0.2, 0, 0, 1)';
590
+ // Entrances for things that appear (never the charts): opacity, 4px blur and a short rise over 300ms,
591
+ // staggered 100ms when several arrive together. Reduced motion gets a plain 200ms fade.
592
+ function animateIn(els, { stagger = 100, rise = 12, duration = 300 } = {}) {
593
+ els.filter(Boolean).forEach((el, i) => {
594
+ el.getAnimations().forEach(a => a.cancel());
595
+ const frames = MOTION
596
+ ? [{ opacity: 0, transform: `translateY(${rise}px)`, filter: 'blur(4px)' }, { opacity: 1, transform: 'none', filter: 'blur(0px)' }]
597
+ : [{ opacity: 0 }, { opacity: 1 }];
598
+ el.animate(frames, { duration: MOTION ? duration : 200, delay: MOTION ? i * stagger : 0, easing: 'ease-out', fill: 'backwards' });
599
+ });
600
+ }
601
+ function reveal(el, show) {
602
+ let from = null;
603
+ if (el._anim) { from = +getComputedStyle(el).opacity; el._anim.cancel(); el._anim = null; } // live value first
604
+ if (!show && el.hidden) return;
605
+ el.hidden = false;
606
+ const a0 = from ?? (show ? 0 : 1), a1 = show ? 1 : 0;
607
+ // Enter: opacity, 4px blur, 12px rise over 300ms. Exit: opacity, 4px blur, -12px over 150ms. Opacity only under reduced motion.
608
+ const rest = { transform: 'none', filter: 'blur(0px)' };
609
+ const away = show ? { transform: 'translateY(12px)', filter: 'blur(4px)' } : { transform: 'translateY(-12px)', filter: 'blur(4px)' };
610
+ const frames = MOTION
611
+ ? [{ opacity: a0, ...(show ? (from === null ? away : rest) : rest) }, { opacity: a1, ...(show ? rest : away) }]
612
+ : [{ opacity: a0 }, { opacity: a1 }];
613
+ const anim = el.animate(frames, { duration: show ? 300 : 150, easing: 'ease-out' });
614
+ el._anim = anim;
615
+ anim.onfinish = () => { el._anim = null; if (!show) el.hidden = true; };
616
+ }
617
+ // Storage keys keep the app's original name so data saved before the rename to JustApply still loads.
618
+ const STORE_KEY = 'appfunnel.files.v1';
619
+ const RANGE_KEY = 'appfunnel.range.v1';
620
+ const OVERRIDE_KEY = 'appfunnel.overrides.v1';
621
+ const SNAP_KEY = 'appfunnel.snapshot.v1';
622
+ const DIFF_KEY = 'appfunnel.lastdiff.v1';
623
+ const DAY = 86400000;
624
+ const $ = (id) => document.getElementById(id);
625
+
626
+ /* ---------- Storage (never required) ---------- */
627
+ const DATA_KEYS = ['appfunnel.files.v1', 'appfunnel.overrides.v1', 'appfunnel.oas.v1'];
628
+ const store = {
629
+ get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
630
+ set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} if (DATA_KEYS.includes(k)) window.__dataChanged?.(); },
631
+ del(k) { try { localStorage.removeItem(k); } catch {} if (DATA_KEYS.includes(k)) window.__dataChanged?.(); },
632
+ };
633
+
634
+ /* ---------- Dates ---------- */
635
+ const today = (() => { const d = new Date(); d.setHours(0,0,0,0); return d; })();
636
+ const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
637
+ function parseDate(s) {
638
+ if (!s) return null;
639
+ s = String(s).trim();
640
+ if (!s || /^(n\/?a|none|null|-)$/i.test(s)) return null;
641
+ let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
642
+ if (m) return new Date(+m[1], +m[2]-1, +m[3]);
643
+ m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
644
+ if (m) { const y = +m[3] < 100 ? 2000 + +m[3] : +m[3]; return new Date(y, +m[1]-1, +m[2]); }
645
+ const t = Date.parse(s);
646
+ if (isNaN(t)) return null;
647
+ const d = new Date(t); d.setHours(0,0,0,0); return d;
648
+ }
649
+ const fmtDate = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
650
+
651
+ /* ---------- Status vocabulary ---------- */
652
+ // Buckets: applied | progress | offer | rejected | withdrawn | excluded
653
+ const STATUS = new Map();
654
+ const add = (bucket, words) => words.forEach(w => STATUS.set(w, bucket));
655
+ add('applied', ['applied','submitted','application submitted','application sent','sent','under review','in review','moved to applied']);
656
+ add('progress', ['screening','screen','phone screen','recruiter screen','oa','online assessment','assessment','coding challenge','take home','interview','interviewing','interviewed','technical interview','onsite','on site','final round','final interview','superday']);
657
+ add('offer', ['offer','offered','offer received','accepted','offer accepted','declined offer','offer declined']);
658
+ add('rejected', ['rejected','rejection','not selected','no longer under consideration','not moving forward','declined by company']);
659
+ add('withdrawn', ['withdrawn','withdrew','withdraw']);
660
+ add('excluded', ['saved','save','bookmarked','wishlist','interested','skipped','skip','needs user','not applied','not interested','draft','to apply','planned','archived']);
661
+ // A rejection outranks a screen for the same job (it's usually the later event); an offer outranks both.
662
+ const RANK = { excluded: 0, applied: 1, withdrawn: 2, progress: 3, rejected: 3.5, offer: 4 };
663
+ const unknownStatuses = new Set();
664
+ function bucketOf(raw) {
665
+ const k = String(raw || '').toLowerCase().replace(/[^a-z ]+/g, ' ').replace(/\s+/g, ' ').trim();
666
+ if (!k) return 'excluded';
667
+ if (STATUS.has(k)) return STATUS.get(k);
668
+ unknownStatuses.add(raw);
669
+ return 'excluded'; // strict: unknown never counts
670
+ }
671
+
672
+ /* ---------- Format detection & row normalization ---------- */
673
+ function findCol(headers, patterns) {
674
+ const norm = headers.map(h => h.toLowerCase().replace(/[^a-z]/g, ''));
675
+ for (const p of patterns) { const i = norm.findIndex(h => p.test(h)); if (i >= 0) return headers[i]; }
676
+ return null;
677
+ }
678
+ function detect(headers) {
679
+ const H = new Set(headers.map(h => h.trim()));
680
+ if (H.has('Company Name') && H.has('Applied Date') && H.has('Status')) return 'simplify';
681
+ if (H.has('Company') && H.has('Role') && H.has('Site') && H.has('Status')) return 'applytwin';
682
+ return 'generic';
683
+ }
684
+ const LABEL = { simplify: 'Simplify', applytwin: 'ApplyTwin', generic: 'CSV' };
685
+
686
+ function normalizeFile(name, text) {
687
+ const parsed = Papa.parse(text.replace(/^/, ''), { header: true, skipEmptyLines: true, transformHeader: h => h.trim() });
688
+ const headers = parsed.meta.fields || [];
689
+ const format = detect(headers);
690
+ let col;
691
+ if (format === 'simplify') col = { company: 'Company Name', role: 'Job Title', status: 'Status', date: 'Applied Date', url: 'Job URL', location: 'Location' };
692
+ else if (format === 'applytwin') col = { company: 'Company', role: 'Role', status: 'Status', date: 'Date', url: 'Link', location: 'Location', notes: 'Notes' };
693
+ else col = {
694
+ company: findCol(headers, [/^company/, /employer/, /organi[sz]ation/]),
695
+ role: findCol(headers, [/^(job)?title/, /role/, /position/]),
696
+ status: findCol(headers, [/^status/, /stage/, /state/]),
697
+ date: findCol(headers, [/applied/, /submitted/, /applicationdate/, /^date/]),
698
+ url: findCol(headers, [/url/, /link/]),
699
+ location: findCol(headers, [/location/]),
700
+ };
701
+ if (!col.status) throw new Error(`"${name}" has no Status column, so applications can't be told apart from saved jobs.`);
702
+ const rows = parsed.data.map(r => {
703
+ const notes = col.notes ? (r[col.notes] || '') : '';
704
+ let date = parseDate(r[col.date]);
705
+ let dateReal = !!date;
706
+ // ApplyTwin imports carry the import timestamp, not the real apply date.
707
+ if (format === 'applytwin' && /^from your history/i.test(notes.trim())) { date = null; dateReal = false; }
708
+ return {
709
+ company: (r[col.company] || '').trim(),
710
+ role: (r[col.role] || '').trim(),
711
+ url: (r[col.url] || '').trim(),
712
+ location: (r[col.location] || '').trim(),
713
+ rawStatus: (r[col.status] || '').trim(),
714
+ bucket: bucketOf(r[col.status]),
715
+ date: dateReal ? date : null,
716
+ statusDate: format === 'simplify' ? parseDate(r['Status Date']) : null,
717
+ source: format,
718
+ };
719
+ });
720
+ return { name, format, rows, total: rows.length, counted: rows.filter(r => r.bucket !== 'excluded').length };
721
+ }
722
+
723
+ /* ---------- Job identity ---------- */
724
+ // A posting's real ID, taken from its link when the link carries one. Two rows with the
725
+ // same ID are the same job; two rows with different IDs are different postings.
726
+ const SHORTLINK = /(^|\.)(simplify\.jobs|go\.vansh\.nyc|bit\.ly|lnkd\.in|tinyurl\.com|t\.co)$/;
727
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
728
+ function parseUrl(u) { try { return /^https?:/i.test(u) ? new URL(u.trim()) : null; } catch { return null; } }
729
+ function strongKey(u, ck) {
730
+ const x = parseUrl(u); if (!x) return '';
731
+ const host = x.hostname.replace(/^www\./, '').toLowerCase();
732
+ if (SHORTLINK.test(host)) return '';
733
+ let path; try { path = decodeURIComponent(x.pathname); } catch { path = x.pathname; }
734
+ const segs = path.split('/').filter(Boolean);
735
+ const idKey = (v) => v.length >= 9 ? 'n:' + v : 'n:' + (ck || host) + ':' + v;
736
+ const uuid = path.match(UUID_RE);
737
+ if (uuid) return 'u:' + uuid[0].toLowerCase();
738
+ if (/myworkdayjobs\.com$/.test(host)) {
739
+ // ..._R0031229, ..._JR-202623439, ..._2642721-1 (the -1/-2 suffix is the same req on another board)
740
+ const m = (segs[segs.length - 1] || '').match(/_([A-Za-z]{0,5}-?\d{3,})(?:-\d{1,2})?$/);
741
+ if (m) return 'wd:' + host.split('.')[0] + ':' + m[1].toUpperCase().replace('-', '');
742
+ }
743
+ for (const p of ['gh_jid', 'token', 'jobId', 'jobid', 'job_id', 'jid', 'rid', 'reqId', 'requisitionId', 'id']) {
744
+ const v = x.searchParams.get(p);
745
+ if (v && /^\d{4,}$/.test(v)) return idKey(v);
746
+ }
747
+ for (let i = segs.length - 1; i >= 0; i--) if (/^\d{5,}$/.test(segs[i])) return idKey(segs[i]);
748
+ const tail = (segs[segs.length - 1] || '').match(/[_-](\d{5,})$/);
749
+ if (tail) return idKey(tail[1]);
750
+ return '';
751
+ }
752
+ // Exact-link key, used only for links that point at a specific page (not a bare homepage).
753
+ function exactUrlKey(u) {
754
+ const x = parseUrl(u); if (!x) return '';
755
+ const path = x.pathname.toLowerCase().replace(/\/(apply|application)\/?$/, '').replace(/\/+$/, '');
756
+ if (!path) return '';
757
+ [...x.searchParams.keys()].forEach(k => { if (/^utm_|^ref$|^source$|^gh_src$|^embed$/i.test(k)) x.searchParams.delete(k); });
758
+ x.searchParams.sort();
759
+ return 'x:' + x.hostname.replace(/^www\./, '').toLowerCase() + path + x.search.toLowerCase();
760
+ }
761
+ function companyKey(c) {
762
+ c = (c || '').toLowerCase();
763
+ if (c.includes('|')) c = c.split('|').pop();
764
+ c = c.replace(/\b(the|inc|llc|ltd|corp|corporation|company|co|plc|careers at|group)\b\.?/g, '').replace(/[^a-z0-9]/g, '');
765
+ return /^unknown/.test(c) ? '' : c;
766
+ }
767
+ const ROLE_STOP = new Set(['or', 'and', 'the', 'of', 'a', 'an', 'in', 'for', 'to', 'with', 'us', 'usa', 'remote', 'hybrid', 'summer', 'fall', 'spring', 'winter', 'autumn', 'new', 'grad']);
768
+ function roleKey(r) {
769
+ const toks = (r || '').toLowerCase()
770
+ .replace(/\([^)]*\)/g, ' ')
771
+ .replace(/\binternship\b/g, 'intern').replace(/\bengineering\b/g, 'engineer').replace(/\bco-?op\b/g, 'intern')
772
+ .replace(/[^a-z0-9+#]+/g, ' ').split(' ')
773
+ .filter(t => t && !ROLE_STOP.has(t) && !/^20\d\d$/.test(t));
774
+ return [...new Set(toks)].sort().join(' ');
775
+ }
776
+ function locTokens(l) {
777
+ l = (l || '').toLowerCase().replace(/\bsf\b/g, 'san francisco').replace(/\bnyc\b/g, 'new york').replace(/\bla\b/g, 'los angeles');
778
+ return new Set(l.split(/[;|,/]+|\bor\b/).map(s => s.replace(/[^a-z ]/g, '').trim()).filter(s => s.length > 2 && s !== 'united states' && s !== 'usa'));
779
+ }
780
+
781
+ /* ---------- Merge & dedupe ---------- */
782
+ // Every row from every file is grouped by job; one group = one application.
783
+ function merge(files) {
784
+ const rows = [];
785
+ files.forEach(f => f.rows.forEach(r => {
786
+ const ck = companyKey(r.company);
787
+ rows.push({ ...r, ck, rk: roleKey(r.role), sk: strongKey(r.url, ck), xk: exactUrlKey(r.url), loc: locTokens(r.location) });
788
+ }));
789
+ const parent = rows.map((_, i) => i);
790
+ const strong = rows.map(r => new Set(r.sk ? [r.sk] : []));
791
+ const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
792
+ const conflict = (a, b) => strong[a].size && strong[b].size && ![...strong[a]].some(k => strong[b].has(k));
793
+ const union = (a, b) => {
794
+ a = find(a); b = find(b);
795
+ if (a === b || conflict(a, b)) return false;
796
+ parent[b] = a; strong[b].forEach(k => strong[a].add(k)); return true;
797
+ };
798
+ // 1) same posting ID, 2) same exact link
799
+ for (const field of ['sk', 'xk']) {
800
+ const seen = new Map();
801
+ rows.forEach((r, i) => { const k = r[field]; if (!k) return; if (seen.has(k)) union(seen.get(k), i); else seen.set(k, i); });
802
+ }
803
+ // 3) rows with no posting ID (shortlinks, homepages) join a group with the same company + role,
804
+ // picking the one whose location overlaps most.
805
+ const byCR = new Map();
806
+ rows.forEach((r, i) => { if (!r.ck || !r.rk) return; const k = r.ck + '|' + r.rk; if (!byCR.has(k)) byCR.set(k, []); byCR.get(k).push(i); });
807
+ rows.forEach((r, i) => {
808
+ if (r.sk || !r.ck || !r.rk) return;
809
+ const me = find(i);
810
+ let best = null, bestScore = -1;
811
+ for (const j of byCR.get(r.ck + '|' + r.rk)) {
812
+ const g = find(j);
813
+ if (g === me || conflict(g, me)) continue;
814
+ const overlap = [...r.loc].filter(t => rows[j].loc.has(t)).length;
815
+ const score = overlap * 10 + (strong[g].size ? 1 : 0);
816
+ if (score > bestScore) { best = g; bestScore = score; }
817
+ }
818
+ if (best !== null) union(best, i);
819
+ });
820
+
821
+ const groups = new Map();
822
+ rows.forEach((r, i) => { const g = find(i); if (!groups.has(g)) groups.set(g, []); groups.get(g).push(r); });
823
+ const all = [];
824
+ let countedRows = 0;
825
+ for (const g of groups.values()) {
826
+ const counted = g.filter(r => r.bucket !== 'excluded');
827
+ countedRows += counted.length;
828
+ const top = g.reduce((a, b) => RANK[b.bucket] > RANK[a.bucket] ? b : a);
829
+ const named = g.find(r => r.company && r.company !== r.company.toLowerCase()) || g.find(r => r.company) || g[0];
830
+ const dates = counted.map(r => r.date).filter(Boolean).sort((a, b) => a - b);
831
+ const keys = new Set();
832
+ g.forEach(r => { if (r.sk) keys.add(r.sk); if (r.xk) keys.add(r.xk); if (r.ck && r.rk) keys.add(r.ck + '|' + r.rk); });
833
+ all.push({
834
+ company: named.company || '(no company)',
835
+ role: (g.find(r => r.source === 'simplify') || named).role,
836
+ location: (g.find(r => r.location) || {}).location || '',
837
+ csvBucket: top.bucket,
838
+ bucket: top.bucket,
839
+ rawStatus: top.rawStatus,
840
+ statusDate: top.statusDate || null,
841
+ url: (g.find(r => r.sk) || g.find(r => parseUrl(r.url) && new URL(r.url).pathname.length > 1) || {}).url || '',
842
+ date: dates[0] || null,
843
+ keys: [...keys].sort(),
844
+ sources: [...new Set(g.map(r => r.source))],
845
+ rowCount: g.length,
846
+ });
847
+ }
848
+ const apps = all.filter(a => a.csvBucket !== 'excluded');
849
+ return { all, apps, countedRows, hasSimplify: files.some(f => f.format === 'simplify') };
850
+ }
851
+ const sharesKey = (a, b) => { const s = new Set(a); return b.some(k => s.has(k)); };
852
+
853
+ /* ---------- Outcomes ---------- */
854
+ function outcomeOf(r) {
855
+ switch (r.bucket) {
856
+ case 'offer': return 'offer';
857
+ case 'progress': return 'progress';
858
+ case 'rejected': return 'rejected';
859
+ case 'withdrawn': return 'withdrawn';
860
+ default:
861
+ if (r.date && (today - r.date) / DAY >= GHOST_DAYS) return 'ghosted';
862
+ return 'waiting';
863
+ }
864
+ }
865
+
866
+ /* ---------- Date ranges ---------- */
867
+ function seasonStart() {
868
+ const y = today.getMonth() >= 7 ? today.getFullYear() : today.getFullYear() - 1;
869
+ return new Date(y, 7, 1);
870
+ }
871
+ const RANGES = [
872
+ { id: 'all', label: 'All time', start: () => null },
873
+ { id: 'season', label: 'This season', start: seasonStart },
874
+ { id: '90', label: 'Last 90 days', start: () => new Date(today - 89 * DAY) },
875
+ { id: '30', label: 'Last 30 days', start: () => new Date(today - 29 * DAY) },
876
+ ];
877
+
878
+ /* ---------- State ---------- */
879
+ let view = 'apps'; // 'apps' | 'oas'
880
+ let firstPaint = true; // the panels' entrance plays once per launch
881
+ let viewReady = false; // switching sections animates only after the first view is shown
882
+ let pendingNew = false; // a fresh What's new card slides in once
883
+ let files = []; // normalized files
884
+ let merged = null;
885
+ let overrides = store.get(OVERRIDE_KEY) || []; // [{ keys, bucket, csvBucket, label }]
886
+ let lastDiff = store.get(DIFF_KEY);
887
+ let showAllNew = false;
888
+ const BUCKET_NAME = { applied: 'Applied', progress: 'Screen / interview', offer: 'Offer', rejected: 'Rejected', withdrawn: 'Withdrawn', excluded: 'Not applied' };
889
+ const BUCKET_COLOR = { applied: 'var(--c-ghosted)', progress: 'var(--chart-3)', offer: 'var(--chart-3)', rejected: 'var(--c-rejected)', withdrawn: 'var(--c-ghosted)', excluded: 'var(--c-ghosted)' };
890
+
891
+ // Your manual edits sit on top of the CSV. An edit is dropped once the CSV's own status for
892
+ // that job changes, because the CSV then has newer information than the edit.
893
+ function applyOverrides() {
894
+ let dropped = false;
895
+ merged.all.forEach(a => { a.bucket = a.csvBucket; a.edited = false; });
896
+ overrides = overrides.filter(o => {
897
+ const a = merged.all.find(x => sharesKey(x.keys, o.keys));
898
+ if (!a) return true; // job may live in a file that isn't loaded right now
899
+ if (a.csvBucket !== o.csvBucket) { dropped = true; return false; }
900
+ a.bucket = o.bucket; a.edited = true; return true;
901
+ });
902
+ if (dropped) store.set(OVERRIDE_KEY, overrides);
903
+ merged.apps = merged.all.filter(a => a.bucket !== 'excluded');
904
+ merged.apps.forEach(a => a.outcome = outcomeOf(a));
905
+ }
906
+
907
+ // What's new = applications (and status changes) not in the dataset as of the previous upload.
908
+ const snapshotOf = (m) => m.all.filter(a => a.csvBucket !== 'excluded').map(a => ({ keys: a.keys, b: a.csvBucket }));
909
+ function diffAgainst(prev, m, names) {
910
+ const idx = new Map();
911
+ prev.forEach(p => p.keys.forEach(k => idx.set(k, p)));
912
+ const added = [], changed = [];
913
+ for (const a of m.all) {
914
+ if (a.csvBucket === 'excluded') continue;
915
+ const p = a.keys.map(k => idx.get(k)).find(Boolean);
916
+ const item = { c: a.company, r: a.role, b: a.csvBucket };
917
+ if (!p) added.push(item);
918
+ else if (p.b !== a.csvBucket) changed.push({ ...item, from: p.b });
919
+ }
920
+ return { at: Date.now(), files: names, added, changed };
921
+ }
922
+ let rangeId = store.get(RANGE_KEY) || 'all';
923
+ if (!RANGES.some(r => r.id === rangeId)) rangeId = 'all';
924
+
925
+ // Every export ever loaded is kept (identical files only once) and all of them are merged, so a job never
926
+ // disappears because a later export dropped it. The newest status still wins because statuses only advance.
927
+ const hashText = (t) => { let h = 2166136261; for (let i = 0; i < t.length; i++) { h ^= t.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36) + ':' + t.length; };
928
+ function loadTexts(list, persist) {
929
+ const err = $('error');
930
+ err.hidden = true;
931
+ try {
932
+ for (const { name, text, at } of list) {
933
+ const h = hashText(text);
934
+ if (files.some(f => f.hash === h)) continue;
935
+ const nf = normalizeFile(name, text);
936
+ nf.text = text; nf.hash = h; nf.at = at || Date.now();
937
+ files.push(nf);
938
+ }
939
+ files.sort((a, b) => (a.format === 'simplify' ? -1 : b.format === 'simplify' ? 1 : 0) || a.at - b.at);
940
+ } catch (e) {
941
+ err.textContent = e.message; err.hidden = false;
942
+ if (files.length) alert(e.message);
943
+ return;
944
+ }
945
+ if (persist) saveFiles();
946
+ if (unknownStatuses.size) console.warn('Unrecognized statuses (not counted):', [...unknownStatuses]);
947
+ merged = merge(files);
948
+ const prev = store.get(SNAP_KEY);
949
+ if (persist && Array.isArray(prev)) {
950
+ const diff = diffAgainst(prev, merged, list.map(x => x.name));
951
+ // Only an upload that added applications or changed a status gets a card; a file with nothing new stays quiet.
952
+ if (diff.added.length || diff.changed.length) {
953
+ lastDiff = diff; showAllNew = false; pendingNew = true;
954
+ store.set(DIFF_KEY, lastDiff);
955
+ } else if (!lastDiff) {
956
+ store.del(DIFF_KEY);
957
+ }
958
+ }
959
+ if (persist || !Array.isArray(prev)) store.set(SNAP_KEY, snapshotOf(merged));
960
+ applyOverrides();
961
+ render();
962
+ }
963
+
964
+ function saveFiles() {
965
+ const payload = files.map(f => ({ name: f.name, text: f.text, at: f.at }));
966
+ window.__dataChanged?.();
967
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(payload)); }
968
+ catch { alert('This device is out of storage for JustApply. Download a backup from the ⋯ menu, then delete old data.'); }
969
+ }
970
+ function readFiles(fileList) {
971
+ const arr = [...fileList].filter(f => /\.csv$/i.test(f.name) || f.type === 'text/csv');
972
+ if (!arr.length) return;
973
+ Promise.all(arr.map(f => f.text().then(text => ({ name: f.name, text })))).then(list => loadTexts(list, true));
974
+ }
975
+
976
+ /* ---------- Tooltip ---------- */
977
+ const tip = $('tip');
978
+ function showTip(evt, build) {
979
+ tip.replaceChildren();
980
+ build(tip);
981
+ tip.style.opacity = 1;
982
+ moveTip(evt);
983
+ }
984
+ function moveTip(evt) {
985
+ const pad = 14, r = tip.getBoundingClientRect();
986
+ let x, y;
987
+ if (evt.clientX !== undefined && evt.type !== 'focus') { x = evt.clientX + pad; y = evt.clientY + pad; }
988
+ else { const b = evt.target.getBoundingClientRect(); x = b.right + pad; y = b.top; }
989
+ if (x + r.width > innerWidth - 8) x = Math.max(8, x - r.width - pad * 2);
990
+ if (y + r.height > innerHeight - 8) y = Math.max(8, y - r.height - pad * 2);
991
+ tip.style.left = x + 'px'; tip.style.top = y + 'px';
992
+ }
993
+ const hideTip = () => { tip.style.opacity = 0; };
994
+ function tipLine(parent, value, label, color) {
995
+ const v = document.createElement('div'); v.className = 'v';
996
+ if (color) { const k = document.createElement('span'); k.className = 'k'; k.style.background = color; v.appendChild(k); }
997
+ v.appendChild(document.createTextNode(value));
998
+ const l = document.createElement('div'); l.textContent = label;
999
+ parent.append(v, l);
1000
+ }
1001
+
1002
+ /* ---------- Render ---------- */
1003
+ const pct = (n, d) => d ? Math.round(n / d * 100) + '%' : '–';
1004
+ const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? '' : 's'}`;
1005
+
1006
+ function render() {
1007
+ const hasData = files.length > 0;
1008
+ $('empty').hidden = hasData;
1009
+ $('dash').hidden = !hasData;
1010
+ $('rangeBtn').hidden = !hasData;
1011
+ $('editBtn').disabled = !hasData;
1012
+ if (view === 'oas') $('rangeBtn').hidden = $('addBtn').hidden = true;
1013
+ $('csvBtn').disabled = !hasData;
1014
+ $('backupBtn').disabled = $('clearBtn').disabled = !hasData && !oas.length;
1015
+ const narrow = matchMedia('(max-width: 640px)').matches;
1016
+ $('addBtn').textContent = narrow ? 'Load' : hasData ? 'Load new export' : 'Load export';
1017
+ const sc = $('scope'); sc.replaceChildren();
1018
+ const span = document.createElement('span');
1019
+ const kinds = [...new Set(files.map(f => LABEL[f.format]))];
1020
+ span.textContent = hasData ? kinds.join(' + ') : 'No files loaded';
1021
+ span.title = files.map(f => f.name).join(', ');
1022
+ sc.appendChild(span);
1023
+ $('historyMeta').textContent = hasData
1024
+ ? `${plural(files.length, 'export')} saved on this device (${kinds.join(' and ')}), ${plural(overrides.length, 'edit')}.`
1025
+ : 'Nothing saved yet.';
1026
+ if (!hasData) { closeDrawer(true); if (firstPaint && view === 'apps') { firstPaint = false; animateIn([$('dropzone')]); } return; }
1027
+
1028
+ const range = RANGES.find(r => r.id === rangeId);
1029
+ $('rangeLabel').textContent = range.label;
1030
+ renderRangeMenu();
1031
+ const start = range.start();
1032
+ const undated = merged.apps.filter(a => !a.date);
1033
+ const apps = start ? merged.apps.filter(a => a.date && a.date >= start) : merged.apps;
1034
+ const rn = $('rangeNote');
1035
+ rn.hidden = !(start && undated.length);
1036
+ rn.textContent = rn.hidden ? '' : `${plural(undated.length, 'application')} with no real apply date ${undated.length === 1 ? 'is' : 'are'} left out of this range; they count under All time.`;
1037
+
1038
+ const c = (o) => apps.filter(a => a.outcome === o).length;
1039
+ console.assert(['waiting', 'ghosted', 'rejected', 'withdrawn', 'progress', 'offer'].reduce((s, o) => s + c(o), 0) === apps.length, 'outcomes do not sum to the total');
1040
+ const heard = c('progress') + c('offer') + c('rejected');
1041
+ [['openReplies', 'replies', heard, heard === 1 ? 'reply' : 'replies', 'var(--chart-3)'],
1042
+ ['openWaiting', 'waiting', c('waiting') + c('ghosted'), 'waiting', 'var(--c-waiting)']].forEach(([id, tab, n, word, color]) => {
1043
+ const b = $(id); b.replaceChildren();
1044
+ const d = document.createElement('span'); d.className = 'dot'; d.style.background = color;
1045
+ // Number and word sit in one text run so the chip's flex gap only separates the dot.
1046
+ const label = document.createElement('span');
1047
+ const strong = document.createElement('b'); strong.textContent = n;
1048
+ label.append(strong, document.createTextNode(' ' + word));
1049
+ b.append(d, label);
1050
+ b.setAttribute('aria-expanded', String(drawerOpen && drawerTab === tab));
1051
+ b.setAttribute('aria-controls', 'drawer');
1052
+ });
1053
+
1054
+ renderWhatsNew();
1055
+ renderFigures(apps);
1056
+ renderSankey(apps);
1057
+ renderCalendar(apps, start, undated.length);
1058
+ renderHeard(apps);
1059
+ renderClock(apps);
1060
+ if (drawerOpen && drawerTab === 'edit') renderEditor();
1061
+ if (firstPaint && view === 'apps') { firstPaint = false; animateIn([...$('dash').querySelectorAll('#rangeNote:not([hidden]), .grid > .panel:not([hidden])')]); }
1062
+ }
1063
+
1064
+ /* ---------- Date-range menu ---------- */
1065
+ function renderRangeMenu() {
1066
+ const m = $('rangeMenu');
1067
+ if (!m.children.length) {
1068
+ RANGES.forEach(r => {
1069
+ const it = document.createElement('button'); it.type = 'button'; it.className = 'menu-item'; it.setAttribute('role', 'menuitemradio'); it.dataset.id = r.id;
1070
+ it.innerHTML = '<svg class="check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1071
+ it.appendChild(document.createTextNode(r.label));
1072
+ it.onclick = () => { rangeId = r.id; store.set(RANGE_KEY, rangeId); closeMenu(true); render(); };
1073
+ m.appendChild(it);
1074
+ });
1075
+ }
1076
+ m.querySelectorAll('.menu-item').forEach(it => it.setAttribute('aria-checked', String(it.dataset.id === rangeId)));
1077
+ }
1078
+
1079
+ /* ---------- Menus (date range, more) ---------- */
1080
+ let openMenuBtn = null;
1081
+ function openMenu(btn) {
1082
+ closeMenu();
1083
+ const menu = $(btn.getAttribute('aria-controls'));
1084
+ openMenuBtn = btn; btn.setAttribute('aria-expanded', 'true');
1085
+ menu.hidden = false;
1086
+ // Scales from the trigger's corner (transform-origin: top right).
1087
+ menu.animate(MOTION ? [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'scale(1)' }] : [{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease-out' });
1088
+ (menu.querySelector('[aria-checked="true"]') || menu.querySelector('.menu-item:not(:disabled)'))?.focus();
1089
+ }
1090
+ function closeMenu(returnFocus) {
1091
+ if (!openMenuBtn) return;
1092
+ const btn = openMenuBtn, menu = $(btn.getAttribute('aria-controls'));
1093
+ openMenuBtn = null; btn.setAttribute('aria-expanded', 'false'); menu.hidden = true;
1094
+ if (returnFocus) btn.focus();
1095
+ }
1096
+ function menuKeys(e) {
1097
+ const items = [...$(openMenuBtn.getAttribute('aria-controls')).querySelectorAll('.menu-item:not(:disabled)')];
1098
+ const i = items.indexOf(document.activeElement);
1099
+ if (e.key === 'Escape') { e.preventDefault(); closeMenu(true); }
1100
+ else if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length]?.focus(); }
1101
+ else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length]?.focus(); }
1102
+ else if (e.key === 'Tab') closeMenu();
1103
+ }
1104
+
1105
+ /* ---------- Drawer ---------- */
1106
+ let drawerOpen = false, drawerTab = 'replies', drawerReturn = null;
1107
+ function openDrawer(tab, from) {
1108
+ const wasOpen = drawerOpen, prevTab = drawerTab;
1109
+ drawerOpen = true; drawerTab = tab;
1110
+ if (!wasOpen) drawerReturn = from || document.activeElement;
1111
+ ['replies', 'waiting', 'edit'].forEach(t => {
1112
+ $('tab-' + t).setAttribute('aria-selected', String(t === tab));
1113
+ $('tab-' + t).tabIndex = t === tab ? 0 : -1;
1114
+ $('pane-' + t).hidden = t !== tab;
1115
+ });
1116
+ const switched = wasOpen && drawerTab !== prevTab;
1117
+ if (!wasOpen) slideDrawer($('drawer'), true);
1118
+ render();
1119
+ if (switched) animateIn([$('pane-' + tab)], { rise: 6, duration: 200 });
1120
+ if (tab === 'edit') $('edSearch').focus({ preventScroll: true });
1121
+ else if (!wasOpen) $('tab-' + tab).focus({ preventScroll: true });
1122
+ }
1123
+ function closeDrawer(instant) {
1124
+ if (!drawerOpen) return;
1125
+ drawerOpen = false;
1126
+ if (instant) $('drawer').hidden = true; else slideDrawer($('drawer'), false);
1127
+ if (!instant && drawerReturn && document.contains(drawerReturn)) drawerReturn.focus({ preventScroll: true });
1128
+ if (merged && files.length) render();
1129
+ }
1130
+ // Enters and leaves along the same path (the right edge; the bottom edge on a phone).
1131
+ // Interruptible: reversing mid-flight starts from the drawer's live opacity.
1132
+ function slideDrawer(el, show) {
1133
+ let from = null;
1134
+ if (el._anim) { from = +getComputedStyle(el).opacity; el._anim.cancel(); el._anim = null; }
1135
+ if (!show && el.hidden) return;
1136
+ el.hidden = false;
1137
+ const off = matchMedia('(max-width: 640px)').matches ? 'translateY(24px)' : 'translateX(24px)';
1138
+ const a0 = from ?? (show ? 0 : 1);
1139
+ const frames = MOTION
1140
+ ? [{ opacity: a0, transform: show && from === null ? off : 'none' }, { opacity: show ? 1 : 0, transform: show ? 'none' : off }]
1141
+ : [{ opacity: a0 }, { opacity: show ? 1 : 0 }];
1142
+ const anim = el.animate(frames, { duration: show ? 300 : 150, easing: show ? EASE : 'ease-out' });
1143
+ el._anim = anim;
1144
+ anim.onfinish = () => { el._anim = null; if (!show) el.hidden = true; };
1145
+ }
1146
+
1147
+ function renderWhatsNew() {
1148
+ const card = $('whatsNew');
1149
+ if (!lastDiff) { if (!card._anim) card.hidden = true; return; }
1150
+ card.hidden = false;
1151
+ if (pendingNew) { pendingNew = false; requestAnimationFrame(() => animateIn([card])); }
1152
+ const d = lastDiff;
1153
+ if (!d.added.length && !d.changed.length) { lastDiff = null; store.del(DIFF_KEY); card.hidden = true; return; }
1154
+ $('newTitle').textContent = "What's new";
1155
+ const when = new Date(d.at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
1156
+ $('newSub').textContent = `From ${d.files.join(' + ')} · ${when}`;
1157
+ const body = $('newBody'); body.replaceChildren();
1158
+ const LIMIT = 8;
1159
+ const section = (title, items, line) => {
1160
+ if (!items.length) return;
1161
+ const sec = document.createElement('div'); sec.className = 'new-sec';
1162
+ const h = document.createElement('h3'); h.textContent = title;
1163
+ const ul = document.createElement('ul'); ul.className = 'list';
1164
+ (showAllNew ? items : items.slice(0, LIMIT)).forEach(it => { const li = document.createElement('li'); line(li, it); ul.appendChild(li); });
1165
+ sec.append(h, ul);
1166
+ if (items.length > LIMIT) {
1167
+ const m = document.createElement('button'); m.className = 'more'; m.type = 'button'; m.style.marginTop = '4px';
1168
+ m.textContent = showAllNew ? 'Show less' : `Show all ${items.length}`;
1169
+ m.onclick = () => { showAllNew = !showAllNew; renderWhatsNew(); };
1170
+ sec.appendChild(m);
1171
+ }
1172
+ body.appendChild(sec);
1173
+ };
1174
+ const dot = (b) => { const s = document.createElement('span'); s.className = 'dot'; s.style.background = BUCKET_COLOR[b]; return s; };
1175
+ const name = (li, it) => {
1176
+ const m = document.createElement('div'); m.className = 'li-main';
1177
+ const a = document.createElement('div'); a.className = 'li-name'; a.textContent = it.c;
1178
+ const b = document.createElement('div'); b.className = 'li-sub'; b.textContent = it.r;
1179
+ m.append(a, b); li.appendChild(m);
1180
+ };
1181
+ section(plural(d.added.length, 'new application'), d.added, (li, it) => { li.appendChild(dot(it.b)); name(li, it); });
1182
+ section(plural(d.changed.length, 'status change'), d.changed, (li, it) => {
1183
+ li.appendChild(dot(it.b)); name(li, it);
1184
+ const s = document.createElement('span'); s.className = 'change'; s.textContent = `${BUCKET_NAME[it.from]} → ${BUCKET_NAME[it.b]}`;
1185
+ li.appendChild(s);
1186
+ });
1187
+ }
1188
+
1189
+ const EDIT_OPTIONS = [['applied', 'Applied'], ['progress', 'Screen / interview'], ['offer', 'Offer'], ['rejected', 'Rejected'], ['withdrawn', 'Withdrawn'], ['excluded', 'Not applied']];
1190
+ function renderEditor() {
1191
+ const ul = $('edList'); ul.replaceChildren();
1192
+ const terms = $('edSearch').value.trim().toLowerCase().split(/\s+/).filter(Boolean);
1193
+ let list = terms.length
1194
+ ? merged.all.filter(a => { const h = (a.company + ' ' + a.role).toLowerCase(); return terms.every(t => h.includes(t)); })
1195
+ : merged.all.filter(a => a.edited);
1196
+ list.sort((a, b) => (b.csvBucket !== 'excluded') - (a.csvBucket !== 'excluded') || a.company.localeCompare(b.company));
1197
+ if (!list.length) {
1198
+ const li = document.createElement('li'); li.className = 'li-sub'; li.style.padding = '10px 0';
1199
+ li.textContent = terms.length ? 'No matching jobs in your CSVs.' : 'Type a company or role to find an application.';
1200
+ ul.appendChild(li); return;
1201
+ }
1202
+ list.slice(0, 12).forEach(a => {
1203
+ const li = document.createElement('li');
1204
+ const main = document.createElement('div'); main.className = 'li-main';
1205
+ const co = document.createElement('div'); co.className = 'li-name'; co.textContent = a.company;
1206
+ if (a.edited) { const t = document.createElement('span'); t.className = 'tag'; t.textContent = 'edited'; co.appendChild(t); }
1207
+ const meta = document.createElement('div'); meta.className = 'li-sub';
1208
+ meta.textContent = [a.role, a.location, a.date ? 'applied ' + fmtDate(a.date) : ''].filter(Boolean).join(' · ');
1209
+ main.append(co, meta);
1210
+ const sel = document.createElement('select');
1211
+ sel.setAttribute('aria-label', `Status for ${a.company} ${a.role}`);
1212
+ const o0 = document.createElement('option'); o0.value = ''; o0.textContent = `From CSV: ${BUCKET_NAME[a.csvBucket]}`; sel.appendChild(o0);
1213
+ EDIT_OPTIONS.forEach(([v, l]) => { if (v === a.csvBucket) return; const o = document.createElement('option'); o.value = v; o.textContent = l; sel.appendChild(o); });
1214
+ sel.value = a.edited ? a.bucket : '';
1215
+ sel.onchange = () => {
1216
+ overrides = overrides.filter(o => !sharesKey(o.keys, a.keys));
1217
+ if (sel.value) overrides.push({ keys: a.keys, bucket: sel.value, csvBucket: a.csvBucket, label: `${a.company} — ${a.role}` });
1218
+ store.set(OVERRIDE_KEY, overrides);
1219
+ applyOverrides(); render();
1220
+ };
1221
+ li.append(main, sel); ul.appendChild(li);
1222
+ });
1223
+ if (list.length > 12) { const li = document.createElement('li'); li.className = 'li-sub'; li.style.padding = '10px 0'; li.textContent = `${list.length - 12} more. Keep typing to narrow it down.`; ul.appendChild(li); }
1224
+ }
1225
+
1226
+ function renderFigures(apps) {
1227
+ const n = apps.length;
1228
+ const c = (o) => apps.filter(a => a.outcome === o).length;
1229
+ const screens = c('progress') + c('offer'), heard = screens + c('rejected');
1230
+ const figs = [
1231
+ { label: 'Heard back', value: heard, suffix: pct(heard, n), lead: true },
1232
+ { label: 'Applied', value: n },
1233
+ { label: 'Screens', value: screens, suffix: n ? pct(screens, n) : '' },
1234
+ { label: 'Offers', value: c('offer') },
1235
+ ];
1236
+ const root = $('figures'); root.replaceChildren();
1237
+ figs.forEach(f => {
1238
+ const el = document.createElement('div'); el.className = 'fig' + (f.lead ? ' lead' : '');
1239
+ const l = document.createElement('div'); l.className = 'fig-label'; l.textContent = f.label;
1240
+ const v = document.createElement('div'); v.className = 'fig-value';
1241
+ if (f.lead) v.appendChild(document.createElement('i'));
1242
+ v.appendChild(document.createTextNode(f.value.toLocaleString()));
1243
+ if (f.suffix) { const sm = document.createElement('small'); sm.textContent = f.suffix; v.appendChild(sm); }
1244
+ el.append(l, v); root.appendChild(el);
1245
+ });
1246
+ }
1247
+
1248
+ // Replies: who answered, newest first.
1249
+ let heardAll = false;
1250
+ // A list row that opens the job posting in a new tab when there is a link; otherwise a plain row.
1251
+ function rowShell(li, a) {
1252
+ if (!a.url) return li;
1253
+ const link = document.createElement('a');
1254
+ link.className = 'row-link'; link.href = a.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
1255
+ link.setAttribute('aria-label', `${a.company}, ${a.role}: open the job posting`);
1256
+ li.appendChild(link);
1257
+ return link;
1258
+ }
1259
+ function renderHeard(apps) {
1260
+ const rows = apps.filter(a => ['progress', 'offer', 'rejected'].includes(a.outcome))
1261
+ .sort((a, b) => (b.statusDate || b.date || 0) - (a.statusDate || a.date || 0));
1262
+ $('heardMeta').textContent = rows.length ? `${rows.length} ${rows.length === 1 ? 'reply' : 'replies'}, newest first` : '';
1263
+ const ul = $('heard'); ul.replaceChildren();
1264
+ if (!rows.length) {
1265
+ const li = document.createElement('li'); const d = document.createElement('div'); d.className = 'empty-line';
1266
+ d.textContent = 'No replies in this range yet. Rejections and screens appear here as your tracker records them.';
1267
+ li.appendChild(d); ul.appendChild(li);
1268
+ }
1269
+ const LIMIT = 6;
1270
+ (heardAll ? rows : rows.slice(0, LIMIT)).forEach(a => {
1271
+ const li = document.createElement('li');
1272
+ const main = document.createElement('div'); main.className = 'li-main';
1273
+ const nm = document.createElement('div'); nm.className = 'li-name'; nm.textContent = a.company;
1274
+ const sub = document.createElement('div'); sub.className = 'li-sub'; sub.textContent = a.role;
1275
+ main.append(nm, sub);
1276
+ const end = document.createElement('div'); end.className = 'li-end';
1277
+ const box = rowShell(li, a);
1278
+ const pill = document.createElement('span');
1279
+ const kind = a.outcome === 'offer' ? ['Offer', 'pill-positive'] : a.outcome === 'progress' ? ['Screen', 'pill-info'] : ['Rejected', 'pill-neutral'];
1280
+ pill.className = 'pill ' + kind[1]; pill.textContent = kind[0];
1281
+ const dt = document.createElement('div'); dt.className = 'li-date';
1282
+ const when = a.statusDate || a.date;
1283
+ dt.textContent = when ? when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';
1284
+ end.append(pill, dt);
1285
+ box.append(main, end); ul.appendChild(li);
1286
+ });
1287
+ const more = $('heardMore');
1288
+ more.hidden = rows.length <= LIMIT;
1289
+ more.textContent = heardAll ? 'Show fewer' : `View all ${rows.length}`;
1290
+ more.onclick = () => { heardAll = !heardAll; renderHeard(apps); };
1291
+ }
1292
+
1293
+ // Click a calendar day: a small panel anchored to that square lists what you applied to.
1294
+ const DAY_STATUS = { waiting: ['Waiting', 'pill-muted'], ghosted: ['No reply', 'pill-muted'], progress: ['Screen', 'pill-info'], offer: ['Offer', 'pill-positive'], rejected: ['Rejected', 'pill-neutral'], withdrawn: ['Withdrawn', 'pill-muted'] };
1295
+ let dayCell = null;
1296
+ function openDay(date, cell, list) {
1297
+ closeDay();
1298
+ const pop = $('dayPop');
1299
+ $('dayTitle').textContent = date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
1300
+ $('dayCount').textContent = plural(list.length, 'application');
1301
+ const ul = $('dayList'); ul.replaceChildren();
1302
+ list.sort((a, b) => a.company.localeCompare(b.company)).forEach(a => {
1303
+ const li = document.createElement('li');
1304
+ const m = document.createElement('div'); m.className = 'li-main';
1305
+ const nm = document.createElement('div'); nm.className = 'li-name'; nm.textContent = a.company;
1306
+ const sub = document.createElement('div'); sub.className = 'li-sub'; sub.textContent = a.role;
1307
+ m.append(nm, sub);
1308
+ const [label, cls] = DAY_STATUS[a.outcome] || DAY_STATUS.waiting;
1309
+ const pill = document.createElement('span'); pill.className = 'pill ' + cls; pill.textContent = label;
1310
+ rowShell(li, a).append(m, pill);
1311
+ ul.appendChild(li);
1312
+ });
1313
+ pop.hidden = false;
1314
+ // Place it under the square (above if there's no room), kept inside the window; it scrolls with the page.
1315
+ const r = cell.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight, gap = 8;
1316
+ const below = r.bottom + gap + h <= innerHeight - 8 || r.top - gap - h < 8;
1317
+ const x = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), innerWidth - w - 8);
1318
+ const y = below ? r.bottom + gap : r.top - gap - h;
1319
+ pop.style.left = (x + scrollX) + 'px'; pop.style.top = (y + scrollY) + 'px';
1320
+ pop.style.transformOrigin = `${r.left + r.width / 2 - x}px ${below ? 0 : h}px`;
1321
+ pop.animate(MOTION ? [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'scale(1)' }] : [{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease-out' });
1322
+ dayCell = cell; cell.classList.add('picked');
1323
+ $('dayClose').focus({ preventScroll: true });
1324
+ }
1325
+ function closeDay(returnFocus) {
1326
+ const pop = $('dayPop');
1327
+ if (pop.hidden) return;
1328
+ pop.hidden = true;
1329
+ if (dayCell) { dayCell.classList.remove('picked'); if (returnFocus && document.contains(dayCell)) dayCell.focus({ preventScroll: true }); }
1330
+ dayCell = null;
1331
+ }
1332
+
1333
+ // Signature element: how long the open applications have waited, against the 30-day ghost line.
1334
+ function renderClock(apps) {
1335
+ const open = apps.filter(a => a.outcome === 'waiting' || a.outcome === 'ghosted');
1336
+ const dated = open.filter(a => a.date);
1337
+ const age = (a) => Math.max(0, Math.floor((today - a.date) / DAY)); // a future-dated row counts as 0 days
1338
+ const bins = [
1339
+ { lbl: '0–6 days', lo: 0, hi: 6 }, { lbl: '7–13 days', lo: 7, hi: 13 }, { lbl: '14–20 days', lo: 14, hi: 20 },
1340
+ { lbl: '21–29 days', lo: 21, hi: 29, soon: true }, { lbl: '30+ days', lo: 30, hi: Infinity, ghost: true },
1341
+ ].map(b => ({ ...b, n: dated.filter(a => age(a) >= b.lo && age(a) <= b.hi).length }));
1342
+ console.assert(bins.reduce((s, b) => s + b.n, 0) === dated.length, 'clock bins do not sum');
1343
+ const max = Math.max(1, ...bins.map(b => b.n));
1344
+ const root = $('clock');
1345
+ if (root.children.length !== bins.length) {
1346
+ root.replaceChildren();
1347
+ bins.forEach(() => {
1348
+ const row = document.createElement('div'); row.tabIndex = 0;
1349
+ const l = document.createElement('span'); l.className = 'lbl';
1350
+ const t = document.createElement('span'); t.className = 'track';
1351
+ t.appendChild(document.createElement('b'));
1352
+ const v = document.createElement('span'); v.className = 'val';
1353
+ row.append(l, t, v); root.appendChild(row);
1354
+ });
1355
+ }
1356
+ bins.forEach((b, i) => {
1357
+ const row = root.children[i];
1358
+ row.className = 'clock-row' + (b.soon && b.n ? ' soon' : '') + (b.ghost ? ' ghost' : '');
1359
+ row.querySelector('.lbl').textContent = b.lbl;
1360
+ row.querySelector('.val').textContent = b.n;
1361
+ row.querySelector('b').style.transform = `scaleX(${b.n / max})`;
1362
+ const inBin = dated.filter(a => age(a) >= b.lo && age(a) <= b.hi).sort((x, y) => x.date - y.date);
1363
+ row.setAttribute('aria-label', `${b.lbl}: ${plural(b.n, 'application')}`);
1364
+ row.onpointerenter = row.onfocus = (evt) => showTip(evt, el => {
1365
+ tipLine(el, plural(b.n, 'application'), `waiting ${b.lbl}`);
1366
+ if (inBin.length) {
1367
+ const d = document.createElement('div'); d.style.marginTop = '4px';
1368
+ d.textContent = inBin.slice(0, 5).map(a => a.company).join(', ') + (inBin.length > 5 ? `, +${inBin.length - 5} more` : '');
1369
+ el.appendChild(d);
1370
+ }
1371
+ });
1372
+ row.onpointermove = moveTip;
1373
+ row.onpointerleave = row.onblur = hideTip;
1374
+ });
1375
+ const ol = $('oldest'); ol.replaceChildren();
1376
+ dated.filter(a => age(a) < GHOST_DAYS).sort((a, b) => a.date - b.date).slice(0, 2).forEach(a => {
1377
+ const li = document.createElement('li');
1378
+ const m = document.createElement('div'); m.className = 'li-main';
1379
+ const nm = document.createElement('div'); nm.className = 'li-name'; nm.textContent = a.company;
1380
+ const sub = document.createElement('div'); sub.className = 'li-sub'; sub.textContent = a.role;
1381
+ m.append(nm, sub);
1382
+ const d = document.createElement('div'); d.className = 'li-end days'; d.textContent = `${age(a)} days`;
1383
+ rowShell(li, a).append(m, d); ol.appendChild(li);
1384
+ });
1385
+ // Next date an application crosses 30 days
1386
+ const next = dated.filter(a => age(a) < GHOST_DAYS).map(a => new Date(+a.date + GHOST_DAYS * DAY)).sort((a, b) => a - b)[0];
1387
+ const nextCount = next ? dated.filter(a => +a.date + GHOST_DAYS * DAY === +next).length : 0;
1388
+ const undated = open.length - dated.length;
1389
+ $('clockNote').textContent = [
1390
+ next ? `The first ${nextCount === 1 ? 'application crosses' : `${nextCount} applications cross`} 30 days on ${next.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}.` : 'Nothing is close to 30 days yet.',
1391
+ undated ? `${undated} open ${undated === 1 ? 'application has' : 'applications have'} no apply date and ${undated === 1 ? 'is' : 'are'} not shown.` : '',
1392
+ ].filter(Boolean).join(' ');
1393
+ }
1394
+
1395
+ // Ink and greys; the one chart hue goes on replies that moved forward.
1396
+ const NODE_COLOR = {
1397
+ applied: 'var(--c-applied)', responded: 'var(--chart-3)', progress: 'var(--chart-3)', offer: 'var(--chart-3)',
1398
+ rejected: 'var(--c-rejected)', rejectedLate: 'var(--c-rejected)', waiting: 'var(--c-waiting)', ghosted: 'var(--c-ghosted)', withdrawn: 'var(--c-ghosted)',
1399
+ };
1400
+ const QUIET = new Set(['waiting', 'ghosted', 'withdrawn']);
1401
+ const linkOpacity = (d, on) => QUIET.has(d.target.id) ? (on ? 0.4 : 0.18) : (on ? 0.7 : 0.4);
1402
+ const NODE_SHORT = { responded: 'Screens', rejectedLate: 'Rejected late', progress: 'In progress' };
1403
+ const NODE_NAME = {
1404
+ applied: 'Applied', responded: 'Screen / interview', progress: 'In progress', offer: 'Offer',
1405
+ rejected: 'Rejected', rejectedLate: 'Rejected after interview', waiting: 'Waiting', ghosted: 'Ghosted', withdrawn: 'Withdrawn',
1406
+ };
1407
+
1408
+ function renderSankey(apps) {
1409
+ const host = $('sankey');
1410
+ const n = apps.length;
1411
+ const c = (o) => apps.filter(a => a.outcome === o).length;
1412
+ const counts = { waiting: c('waiting'), ghosted: c('ghosted'), rejected: c('rejected'), withdrawn: c('withdrawn'), progress: c('progress'), offer: c('offer'), rejectedLate: 0 };
1413
+ const respondedTotal = counts.progress + counts.offer + counts.rejectedLate;
1414
+ const secondLevel = counts.offer + counts.rejectedLate > 0; // only split when there's something beyond "in progress"
1415
+
1416
+ const hasAT = files.some(f => f.format === 'applytwin');
1417
+ $('sankeyNote').textContent = hasAT
1418
+ ? `Replies come from Simplify. ApplyTwin records no replies, so jobs only in ApplyTwin count as waiting until 30 days pass.`
1419
+ : '';
1420
+ if (!n) { host.replaceChildren(); const p = document.createElement('p'); p.className = 'note'; p.textContent = 'No applications in this date range.'; host.appendChild(p); return; }
1421
+
1422
+ const nodes = [{ id: 'applied' }];
1423
+ const links = [];
1424
+ const pushLink = (s, t, v) => { if (v > 0) { if (!nodes.some(x => x.id === t)) nodes.push({ id: t }); links.push({ source: s, target: t, value: v }); } };
1425
+ if (respondedTotal) {
1426
+ if (secondLevel) {
1427
+ pushLink('applied', 'responded', respondedTotal);
1428
+ pushLink('responded', 'progress', counts.progress);
1429
+ pushLink('responded', 'offer', counts.offer);
1430
+ pushLink('responded', 'rejectedLate', counts.rejectedLate);
1431
+ } else pushLink('applied', 'responded', counts.progress);
1432
+ }
1433
+ pushLink('applied', 'rejected', counts.rejected);
1434
+ pushLink('applied', 'withdrawn', counts.withdrawn);
1435
+ pushLink('applied', 'waiting', counts.waiting);
1436
+ pushLink('applied', 'ghosted', counts.ghosted);
1437
+
1438
+ const W = host.clientWidth || 800;
1439
+ const narrow = W < 560;
1440
+ const H = Math.max(228, Math.min(400, 96 + nodes.length * 32));
1441
+ const labelW = narrow ? 112 : 200;
1442
+ const m = { top: 8, right: labelW, bottom: 8, left: narrow ? 64 : 96 };
1443
+
1444
+ const sk = d3.sankey()
1445
+ .nodeId(d => d.id)
1446
+ .nodeAlign(d3.sankeyLeft)
1447
+ .nodeWidth(10)
1448
+ .nodePadding(narrow ? 14 : 20)
1449
+ .nodeSort(null)
1450
+ .extent([[m.left, m.top], [W - m.right, H - m.bottom]]);
1451
+ const graph = sk({ nodes: nodes.map(d => ({ ...d })), links: links.map(d => ({ ...d })) });
1452
+
1453
+ // Reuse the SVG and update the shapes in place when the range changes.
1454
+ let svg = d3.select(host).select('svg');
1455
+ if (svg.empty() || +svg.attr('width') !== W) {
1456
+ host.replaceChildren();
1457
+ svg = d3.select(host).append('svg');
1458
+ svg.append('g').attr('class', 'links');
1459
+ svg.append('g').attr('class', 'nodes');
1460
+ }
1461
+ svg.attr('width', W).attr('height', H).attr('role', 'img')
1462
+ .attr('aria-label', 'Funnel: ' + graph.nodes.map(d => `${NODE_NAME[d.id]} ${d.value}`).join(', '));
1463
+ const t = d3.transition().duration(0); // charts update in place, without animating
1464
+ const path = d3.sankeyLinkHorizontal();
1465
+
1466
+ const linkSel = svg.select('g.links').selectAll('path').data(graph.links, d => d.target.id)
1467
+ .join(
1468
+ enter => enter.append('path').attr('class', 's-link')
1469
+ .attr('d', path).attr('stroke', d => NODE_COLOR[d.target.id]).attr('stroke-width', 0)
1470
+ .style('stroke-opacity', d => linkOpacity(d, false)),
1471
+ update => update,
1472
+ exit => exit.transition(t).attr('stroke-width', 0).style('opacity', 0).remove()
1473
+ )
1474
+ .attr('tabindex', 0);
1475
+ linkSel.transition(t).attr('d', path).attr('stroke-width', d => Math.max(2, d.width));
1476
+ linkSel
1477
+ .on('pointerenter focus', function (evt, d) {
1478
+ d3.select(this).style('stroke-opacity', linkOpacity(d, true));
1479
+ showTip(evt, el => tipLine(el, `${d.value} · ${pct(d.value, n)}`, `${NODE_NAME[d.source.id]} → ${NODE_NAME[d.target.id]}`, NODE_COLOR[d.target.id]));
1480
+ })
1481
+ .on('pointermove', moveTip)
1482
+ .on('pointerleave blur', function (evt, d) { d3.select(this).style('stroke-opacity', linkOpacity(d, false)); hideTip(); });
1483
+
1484
+ const node = svg.select('g.nodes').selectAll('g.s-node').data(graph.nodes, d => d.id)
1485
+ .join(
1486
+ enter => {
1487
+ const g = enter.append('g').attr('class', 's-node').style('opacity', 0);
1488
+ g.append('rect').attr('class', 'bar').attr('fill', d => NODE_COLOR[d.id])
1489
+ .attr('x', d => d.x0).attr('y', d => d.y0).attr('width', d => d.x1 - d.x0).attr('height', d => Math.max(3, d.y1 - d.y0));
1490
+ g.append('rect').attr('class', 'hit').attr('fill', 'transparent').attr('tabindex', 0);
1491
+ g.append('text').attr('class', 's-label').attr('dy', '0.35em').attr('pointer-events', 'none')
1492
+ .attr('y', d => (d.y0 + d.y1) / 2);
1493
+ return g;
1494
+ },
1495
+ update => update,
1496
+ exit => exit.transition(t).style('opacity', 0).remove()
1497
+ );
1498
+ node.transition(t).style('opacity', 1);
1499
+ node.select('rect.bar').transition(t)
1500
+ .attr('x', d => d.x0).attr('y', d => d.y0).attr('width', d => d.x1 - d.x0).attr('height', d => Math.max(3, d.y1 - d.y0));
1501
+ // Bigger invisible hit target around each node
1502
+ node.select('rect.hit')
1503
+ .attr('x', d => d.x0 - 8).attr('y', d => d.y0 - 4)
1504
+ .attr('width', d => d.x1 - d.x0 + 16).attr('height', d => Math.max(3, d.y1 - d.y0) + 8)
1505
+ .on('pointerenter focus', (evt, d) => {
1506
+ linkSel.style('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? linkOpacity(l, true) : 0.08);
1507
+ showTip(evt, el => tipLine(el, `${d.value} · ${pct(d.value, n)}`, NODE_NAME[d.id], NODE_COLOR[d.id]));
1508
+ })
1509
+ .on('pointermove', moveTip)
1510
+ .on('pointerleave blur', () => { linkSel.style('stroke-opacity', l => linkOpacity(l, false)); hideTip(); })
1511
+ .on('click keydown', (evt, d) => {
1512
+ if (evt.type === 'keydown' && evt.key !== 'Enter' && evt.key !== ' ') return;
1513
+ const tab = ['responded', 'progress', 'offer', 'rejected', 'rejectedLate'].includes(d.id) ? 'replies' : ['waiting', 'ghosted'].includes(d.id) ? 'waiting' : null;
1514
+ if (tab) { evt.preventDefault?.(); hideTip(); openDrawer(tab, evt.currentTarget); }
1515
+ })
1516
+ .style('cursor', d => d.id === 'applied' || d.id === 'withdrawn' ? null : 'pointer');
1517
+
1518
+ // Labels: the numbers change at once (they are the facts); only their position eases.
1519
+ const label = node.select('text.s-label')
1520
+ .attr('x', d => d.id === 'applied' ? d.x0 - 10 : d.x1 + 10)
1521
+ .attr('text-anchor', d => d.id === 'applied' ? 'end' : 'start');
1522
+ label.transition(t).attr('y', d => (d.y0 + d.y1) / 2);
1523
+ label.each(function (d) {
1524
+ const tx = d3.select(this); tx.selectAll('tspan').remove();
1525
+ if (d.id === 'applied') {
1526
+ tx.append('tspan').attr('class', 'n').attr('x', d.x0 - 10).attr('dy', '-0.25em').text(d.value);
1527
+ tx.append('tspan').attr('x', d.x0 - 10).attr('dy', '1.3em').text('Applied');
1528
+ return;
1529
+ }
1530
+ tx.append('tspan').text(((narrow && NODE_SHORT[d.id]) || NODE_NAME[d.id]) + ' ');
1531
+ tx.append('tspan').attr('class', 'n').text(d.value);
1532
+ if (!narrow) tx.append('tspan').attr('class', 'p').text(' ' + pct(d.value, n));
1533
+ });
1534
+ }
1535
+
1536
+ function renderCalendar(apps, start, undatedCount) {
1537
+ closeDay();
1538
+ const host = $('calendar'); host.replaceChildren();
1539
+ const dated = apps.filter(a => a.date);
1540
+ const byDay = d3.rollup(dated, v => v.length, a => ymd(a.date));
1541
+ const stats = $('calStats'); stats.replaceChildren();
1542
+ $('calNote').textContent = undatedCount
1543
+ ? `${plural(undatedCount, 'application')} imported into ApplyTwin "from your history" ${undatedCount === 1 ? 'has' : 'have'} no real apply date, so ${undatedCount === 1 ? "it isn't" : "they aren't"} shown here.` : '';
1544
+ if (!dated.length) { const p = document.createElement('p'); p.className = 'note'; p.textContent = 'No dated applications in this range.'; host.appendChild(p); return; }
1545
+
1546
+ const minDate = start || d3.min(dated, a => a.date);
1547
+ // Show at least ~16 weeks so a short history still reads as a calendar.
1548
+ let first = d3.timeWeek.floor(minDate);
1549
+ const minSpan = d3.timeWeek.offset(d3.timeWeek.floor(today), -15);
1550
+ if (first > minSpan) first = minSpan;
1551
+ const days = d3.timeDay.range(first, d3.timeDay.offset(today, 1));
1552
+ const weeks = d3.timeWeek.count(first, today) + 1;
1553
+
1554
+ const gap = 3, left = 30, top = 20, right = 24;
1555
+ const avail = (host.parentElement.clientWidth || 600) - left - right;
1556
+ const cell = Math.max(11, Math.min(24, Math.floor(avail / weeks) - gap));
1557
+ const step = cell + gap;
1558
+ const W = left + weeks * step + right, H = top + 7 * step;
1559
+ const max = d3.max([...byDay.values()]) || 1;
1560
+ const level = (v) => !v ? 0 : max <= 4 ? Math.min(4, v) : Math.min(4, Math.ceil(v / max * 4));
1561
+ const heat = ['var(--heat-0)', 'var(--heat-1)', 'var(--heat-2)', 'var(--heat-3)', 'var(--heat-4)'];
1562
+
1563
+ const svg = d3.select(host).append('svg').attr('width', W).attr('height', H).attr('role', 'img')
1564
+ .attr('aria-label', `Applications per day, ${dated.length} total`);
1565
+ [['Mon', 1], ['Wed', 3], ['Fri', 5]].forEach(([t, i]) =>
1566
+ svg.append('text').attr('class', 'cal-axis').attr('x', 0).attr('y', top + i * step + cell / 2 + 4).text(t));
1567
+ let lastMonth = -1;
1568
+ for (let w = 0; w < weeks; w++) {
1569
+ const d = d3.timeWeek.offset(first, w);
1570
+ const dd = d3.timeDay.offset(d, 6) > today ? d : d3.timeDay.offset(d, 6);
1571
+ const mo = (d.getDate() <= 7 ? d : dd).getMonth();
1572
+ if (mo !== lastMonth && (d.getDate() <= 7 || w === 0)) {
1573
+ svg.append('text').attr('class', 'cal-axis').attr('x', left + w * step).attr('y', 11)
1574
+ .text(new Date(d.getFullYear(), mo, 1).toLocaleDateString(undefined, { month: 'short' }));
1575
+ lastMonth = mo;
1576
+ }
1577
+ }
1578
+ svg.append('g').selectAll('rect').data(days).join('rect')
1579
+ .attr('class', 'cal-cell')
1580
+ .attr('width', cell).attr('height', cell)
1581
+ .attr('x', d => left + d3.timeWeek.count(first, d) * step)
1582
+ .attr('y', d => top + d.getDay() * step)
1583
+ .attr('fill', d => heat[level(byDay.get(ymd(d)))])
1584
+ .attr('opacity', d => (start && d < start) ? 0.35 : 1)
1585
+ .attr('tabindex', d => byDay.get(ymd(d)) ? 0 : -1)
1586
+ .on('pointerenter focus', (evt, d) => {
1587
+ const v = byDay.get(ymd(d)) || 0;
1588
+ showTip(evt, el => tipLine(el, plural(v, 'application'), d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })));
1589
+ })
1590
+ .on('pointermove', moveTip)
1591
+ .on('pointerleave blur', hideTip)
1592
+ .classed('has-apps', d => !!byDay.get(ymd(d)))
1593
+ .attr('role', d => byDay.get(ymd(d)) ? 'button' : null)
1594
+ .attr('aria-label', d => { const v = byDay.get(ymd(d)); return v ? `${plural(v, 'application')} on ${d.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })}. Show them` : null; })
1595
+ .on('click keydown', (evt, d) => {
1596
+ if (evt.type === 'keydown' && evt.key !== 'Enter' && evt.key !== ' ') return;
1597
+ if (!byDay.get(ymd(d))) return;
1598
+ evt.preventDefault(); hideTip();
1599
+ openDay(d, evt.currentTarget, apps.filter(a => a.date && ymd(a.date) === ymd(d)));
1600
+ });
1601
+
1602
+ // Stats
1603
+ let busiest = null; byDay.forEach((v, k) => { if (!busiest || v > busiest[1]) busiest = [k, v]; });
1604
+ let streak = 0; let d = byDay.get(ymd(today)) ? today : d3.timeDay.offset(today, -1);
1605
+ while (byDay.get(ymd(d))) { streak++; d = d3.timeDay.offset(d, -1); }
1606
+ const spanStart = start || d3.min(dated, a => a.date);
1607
+ const spanWeeks = Math.max(1, (today - spanStart) / DAY / 7);
1608
+ const items = [
1609
+ [busiest ? `${busiest[1]}` : '–', busiest ? `Busiest day · ${fmtDate(parseDate(busiest[0]))}` : 'Busiest day'],
1610
+ [String(streak), 'Day streak'],
1611
+ [(dated.length / spanWeeks).toFixed(1), 'Applications per week'],
1612
+ [String(byDay.size), 'Days you applied'],
1613
+ ];
1614
+ items.forEach(([v, l]) => {
1615
+ const el = document.createElement('div'); el.className = 'stat';
1616
+ const a = document.createElement('div'); a.className = 'stat-v'; a.textContent = v;
1617
+ const b = document.createElement('div'); b.className = 'stat-l'; b.textContent = l;
1618
+ el.append(a, b); stats.appendChild(el);
1619
+ });
1620
+ }
1621
+
1622
+ /* ---------- Wiring ---------- */
1623
+ const input = $('fileInput');
1624
+ input.addEventListener('change', () => { readFiles(input.files); input.value = ''; });
1625
+ $('dropzone').addEventListener('click', () => input.click());
1626
+ $('dropzone').addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
1627
+ $('addBtn').addEventListener('click', () => input.click());
1628
+ $('clearBtn').addEventListener('click', () => {
1629
+ closeMenu();
1630
+ if (!confirm(`Delete all ${plural(files.length, 'saved export')}, ${plural(overrides.length, 'edit')} and ${plural(oas.length, 'OA')} from this device${fileState === 'on' ? ` (and empty ${fileHandle.name})` : ''}? Download a backup first if you might want them back.`)) return;
1631
+ files = []; merged = null; lastDiff = null; overrides = []; oas = [];
1632
+ [STORE_KEY, SNAP_KEY, DIFF_KEY, OVERRIDE_KEY, OA_KEY].forEach(k => store.del(k));
1633
+ render(); renderOAs();
1634
+ });
1635
+ $('backupBtn').addEventListener('click', () => {
1636
+ closeMenu();
1637
+ const data = { app: 'JustApply', version: 1, savedAt: new Date().toISOString(), files: files.map(f => ({ name: f.name, text: f.text, at: f.at })), overrides, oas };
1638
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
1639
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
1640
+ a.download = `justapply-backup-${ymd(today)}.json`; document.body.appendChild(a); a.click(); a.remove();
1641
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1642
+ });
1643
+ // One row per application, after merging and dedupe, with your edits applied. Status words are ones this app
1644
+ // reads back, so the file can be dropped in again later.
1645
+ $('csvBtn').addEventListener('click', () => {
1646
+ closeMenu();
1647
+ const STATUS_WORD = { applied: 'Applied', progress: 'Screening', offer: 'Offer', rejected: 'Rejected', withdrawn: 'Withdrawn' };
1648
+ const OUTCOME = { waiting: 'Waiting for reply', ghosted: 'No reply after 30 days', progress: 'Screen / interview', offer: 'Offer', rejected: 'Rejected', withdrawn: 'Withdrawn' };
1649
+ const esc = (v) => { v = v == null ? '' : String(v); return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
1650
+ const head = ['Company', 'Role', 'Location', 'Status', 'Outcome', 'Applied date', 'Days since applied', 'Reply date', 'Source', 'Edited by you', 'Link'];
1651
+ const rows = [...merged.apps]
1652
+ .sort((a, b) => (b.date || 0) - (a.date || 0) || a.company.localeCompare(b.company))
1653
+ .map(a => [
1654
+ a.company, a.role, a.location, STATUS_WORD[a.bucket], OUTCOME[a.outcome],
1655
+ a.date ? ymd(a.date) : '', a.date ? Math.max(0, Math.floor((today - a.date) / DAY)) : '',
1656
+ ['progress', 'offer', 'rejected'].includes(a.bucket) && a.statusDate ? ymd(a.statusDate) : '',
1657
+ a.sources.map(x => LABEL[x]).join(' + '), a.edited ? 'Yes' : '', a.url,
1658
+ ]);
1659
+ const csv = '\uFEFF' + [head, ...rows].map(r => r.map(esc).join(',')).join('\r\n'); // BOM so Excel reads UTF-8
1660
+ const a = document.createElement('a');
1661
+ a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
1662
+ a.download = `justapply-applications-${ymd(today)}.csv`; document.body.appendChild(a); a.click(); a.remove();
1663
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1664
+ });
1665
+ $('restoreBtn').addEventListener('click', () => { closeMenu(); $('restoreInput').click(); });
1666
+ $('restoreInput').addEventListener('change', async (e) => {
1667
+ const f = e.target.files[0]; e.target.value = '';
1668
+ if (!f) return;
1669
+ try {
1670
+ const data = JSON.parse(await f.text());
1671
+ if (!['JustApply', 'App Funnel'].includes(data.app) || !Array.isArray(data.files)) throw new Error('not a backup');
1672
+ // Restoring adds to what is here; nothing on this device is overwritten.
1673
+ (data.overrides || []).forEach(o => { if (!overrides.some(x => sharesKey(x.keys, o.keys))) overrides.push(o); });
1674
+ store.set(OVERRIDE_KEY, overrides);
1675
+ (data.oas || []).forEach(o => { const cur = oas.find(x => x.id === o.id); if (!cur) oas.push(o); else if ((o.updatedAt || 0) > (cur.updatedAt || 0)) Object.assign(cur, o); });
1676
+ saveOAs();
1677
+ if (data.files.length) loadTexts(data.files, true); else render();
1678
+ if (view === 'oas') renderOAs();
1679
+ } catch { alert("That file isn't a JustApply backup."); }
1680
+ });
1681
+ $('newDismiss').addEventListener('click', () => { lastDiff = null; store.del(DIFF_KEY); reveal($('whatsNew'), false); });
1682
+ $('editBtn').addEventListener('click', () => { closeMenu(); if (view !== 'apps') location.hash = '#apps'; openDrawer('edit', $('moreBtn')); });
1683
+ $('openReplies').addEventListener('click', (e) => (drawerOpen && drawerTab === 'replies') ? closeDrawer() : openDrawer('replies', e.currentTarget));
1684
+ $('openWaiting').addEventListener('click', (e) => (drawerOpen && drawerTab === 'waiting') ? closeDrawer() : openDrawer('waiting', e.currentTarget));
1685
+ $('drawerClose').addEventListener('click', () => closeDrawer());
1686
+ ['replies', 'waiting', 'edit'].forEach(t => $('tab-' + t).addEventListener('click', () => openDrawer(t)));
1687
+ $('drawer').querySelector('.tabs').addEventListener('keydown', (e) => {
1688
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
1689
+ e.preventDefault();
1690
+ const order = ['replies', 'waiting', 'edit'], i = order.indexOf(drawerTab);
1691
+ const t = order[(i + (e.key === 'ArrowRight' ? 1 : order.length - 1)) % order.length];
1692
+ openDrawer(t); $('tab-' + t).focus();
1693
+ });
1694
+ ['rangeBtn', 'moreBtn'].forEach(id => $(id).addEventListener('click', (e) => openMenuBtn === e.currentTarget ? closeMenu() : openMenu(e.currentTarget)));
1695
+ document.addEventListener('keydown', (e) => {
1696
+ if (openMenuBtn) return menuKeys(e);
1697
+ if (e.key === 'Escape' && !$('dayPop').hidden) { closeDay(true); return; }
1698
+ if (e.key === 'Escape' && drawerOpen) closeDrawer();
1699
+ });
1700
+ document.addEventListener('pointerdown', (e) => {
1701
+ if (openMenuBtn && !e.target.closest('.menu-wrap')) closeMenu();
1702
+ if (!$('dayPop').hidden && !e.target.closest('#dayPop') && !e.target.closest('.cal-cell.has-apps')) closeDay();
1703
+ });
1704
+ $('dayClose').addEventListener('click', () => closeDay(true));
1705
+ addEventListener('resize', () => closeDay());
1706
+
1707
+ $('edSearch').addEventListener('input', renderEditor);
1708
+ window.__appfunnel = () => merged;
1709
+ addEventListener('scroll', () => $('topbar').classList.toggle('scrolled', scrollY > 4), { passive: true }); // for debugging in the console
1710
+
1711
+ let dragDepth = 0;
1712
+ const overlay = $('overlay');
1713
+ addEventListener('dragenter', e => { e.preventDefault(); dragDepth++; if (files.length) overlay.classList.add('on'); else $('dropzone').classList.add('over'); });
1714
+ addEventListener('dragover', e => e.preventDefault());
1715
+ addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; overlay.classList.remove('on'); $('dropzone').classList.remove('over'); } });
1716
+ addEventListener('drop', e => {
1717
+ e.preventDefault(); dragDepth = 0; overlay.classList.remove('on'); $('dropzone').classList.remove('over');
1718
+ if (e.dataTransfer?.files?.length) readFiles(e.dataTransfer.files);
1719
+ });
1720
+ let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => merged && render(), 120); });
1721
+
1722
+ /* ---------- OAs (separate section) ---------- */
1723
+ // Each OA: { id, company, role, date 'YYYY-MM-DD', platform, score, questions: [{ topic, name, outcome }], well, wrong, next, createdAt, updatedAt }
1724
+ const OA_KEY = 'appfunnel.oas.v1';
1725
+ let oas = store.get(OA_KEY) || [];
1726
+ const OUTCOMES = [['solved', 'Solved'], ['partial', 'Partial'], ['missed', 'Not solved']];
1727
+ const OUT_CLASS = { solved: 'k-solved', partial: 'k-partial', missed: 'k-missed' };
1728
+ const TOPICS = ['Arrays', 'Strings', 'Hash maps', 'Two pointers', 'Sliding window', 'Binary search', 'Sorting', 'Stacks', 'Queues', 'Linked lists', 'Trees', 'Graphs', 'BFS / DFS', 'Heaps', 'Greedy', 'Dynamic programming', 'Backtracking', 'Intervals', 'Math', 'Bit manipulation', 'Recursion', 'SQL', 'Debugging', 'System design'];
1729
+ const saveOAs = () => store.set(OA_KEY, oas);
1730
+ const newId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2));
1731
+ const topicKey = (t) => t.trim().toLowerCase().replace(/\s+/g, ' ');
1732
+
1733
+ function setView(v) {
1734
+ view = v;
1735
+ $('appsView').hidden = v !== 'apps';
1736
+ $('oaView').hidden = v !== 'oas';
1737
+ $('navApps').setAttribute('aria-current', v === 'apps' ? 'page' : 'false');
1738
+ $('navOas').setAttribute('aria-current', v === 'oas' ? 'page' : 'false');
1739
+ $('newOaBtn').hidden = v !== 'oas';
1740
+ $('addBtn').hidden = v !== 'apps';
1741
+ $('scope').style.visibility = v === 'apps' ? '' : 'hidden';
1742
+ if (v === 'oas') { closeDrawer(true); $('rangeBtn').hidden = true; renderOAs(); }
1743
+ else { closeOA(true); render(); }
1744
+ document.title = v === 'oas' ? 'OAs · JustApply' : 'JustApply';
1745
+ if (viewReady) animateIn([$(v === 'oas' ? 'oaView' : 'appsView')], { rise: 8, duration: 220 });
1746
+ else if (v === 'oas') { firstPaint = false; animateIn([...$('oaView').querySelectorAll('#oaEmpty:not([hidden]), #oaGrid:not([hidden]) > .panel')]); }
1747
+ viewReady = true;
1748
+ }
1749
+
1750
+ function renderOAs() {
1751
+ const has = oas.length > 0;
1752
+ $('oaEmpty').hidden = has;
1753
+ $('oaGrid').hidden = !has;
1754
+ if (!has) return;
1755
+ const sorted = [...oas].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.createdAt - a.createdAt);
1756
+ const qCount = oas.reduce((s, o) => s + o.questions.length, 0);
1757
+ $('oaMeta').textContent = `${plural(oas.length, 'OA')}, ${plural(qCount, 'question')}`;
1758
+
1759
+ // OA list
1760
+ const ul = $('oaList'); ul.replaceChildren();
1761
+ sorted.forEach(o => {
1762
+ const li = document.createElement('li');
1763
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'oa-row';
1764
+ b.setAttribute('aria-label', `Open the ${o.company || 'untitled'} OA`);
1765
+ const m = document.createElement('div'); m.className = 'li-main';
1766
+ const nm = document.createElement('div'); nm.className = 'li-name'; nm.textContent = o.company || 'Untitled OA';
1767
+ const sub = document.createElement('div'); sub.className = 'li-sub';
1768
+ sub.textContent = [o.role, o.platform, o.score].filter(Boolean).join(' · ') || 'No details yet';
1769
+ m.append(nm, sub);
1770
+ const end = document.createElement('div'); end.className = 'li-end';
1771
+ const dt = document.createElement('div'); dt.className = 'li-date'; dt.style.marginTop = '0';
1772
+ dt.textContent = o.date ? parseDate(o.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';
1773
+ const dots = document.createElement('div'); dots.className = 'qdots'; dots.style.marginTop = '6px';
1774
+ o.questions.forEach(q => { const i = document.createElement('i'); i.className = OUT_CLASS[q.outcome]; dots.appendChild(i); });
1775
+ end.append(dt, dots);
1776
+ b.append(m, end); b.onclick = () => openOA(o.id, b);
1777
+ li.appendChild(b); ul.appendChild(li);
1778
+ if (o.id === freshOaId) { freshOaId = null; animateIn([li], { rise: 8, duration: 260 }); }
1779
+ });
1780
+
1781
+ // Weak spots: per topic, solved / partial / missed; most trouble first.
1782
+ const byTopic = new Map();
1783
+ oas.forEach(o => o.questions.forEach(q => {
1784
+ if (!q.topic.trim()) return;
1785
+ const k = topicKey(q.topic);
1786
+ if (!byTopic.has(k)) byTopic.set(k, { label: q.topic.trim(), solved: 0, partial: 0, missed: 0 });
1787
+ byTopic.get(k)[q.outcome]++;
1788
+ }));
1789
+ const topics = [...byTopic.values()].map(t => ({ ...t, total: t.solved + t.partial + t.missed, trouble: t.missed * 2 + t.partial }))
1790
+ .sort((a, b) => b.trouble - a.trouble || b.total - a.total || a.label.localeCompare(b.label));
1791
+ const max = Math.max(1, ...topics.map(t => t.total));
1792
+ const weak = $('weak'); weak.replaceChildren();
1793
+ if (!topics.length) {
1794
+ const d = document.createElement('div'); d.className = 'empty-line'; d.textContent = 'Add questions with a topic to your OAs to see which topics trip you up.';
1795
+ weak.appendChild(d);
1796
+ }
1797
+ topics.slice(0, 10).forEach(t => {
1798
+ const row = document.createElement('div'); row.className = 'weak-row';
1799
+ const l = document.createElement('span'); l.className = 'lbl'; l.textContent = t.label;
1800
+ const st = document.createElement('span'); st.className = 'stack'; st.style.width = (t.total / max * 100) + '%';
1801
+ [['solved', t.solved], ['partial', t.partial], ['missed', t.missed]].forEach(([k, n]) => { if (!n) return; const i = document.createElement('i'); i.className = OUT_CLASS[k]; i.style.flex = n; st.appendChild(i); });
1802
+ const v = document.createElement('span'); v.className = 'val'; v.textContent = `${t.solved} of ${t.total} solved`;
1803
+ row.append(l, st, v); weak.appendChild(row);
1804
+ });
1805
+
1806
+ // Latest "next time" notes
1807
+ const dg = $('digest'); dg.replaceChildren();
1808
+ const notes = sorted.filter(o => o.next && o.next.trim()).slice(0, 3);
1809
+ if (!notes.length) { const li = document.createElement('li'); li.className = 'li-sub'; li.textContent = 'Your "Next time" notes will collect here.'; dg.appendChild(li); }
1810
+ notes.forEach(o => {
1811
+ const li = document.createElement('li');
1812
+ const t = document.createElement('div'); t.textContent = o.next.trim();
1813
+ const f = document.createElement('div'); f.className = 'from'; f.textContent = [o.company, o.date && parseDate(o.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })].filter(Boolean).join(' · ');
1814
+ li.append(t, f); dg.appendChild(li);
1815
+ });
1816
+ }
1817
+
1818
+ /* OA side panel: every change saves itself, with a quiet "Saved" stamp. */
1819
+ let editingId = null, oaReturn = null, savedTimer = null, freshOaId = null;
1820
+ function fillLists() {
1821
+ const companies = [...new Set([...(merged ? merged.apps.map(a => a.company) : []), ...oas.map(o => o.company)].filter(Boolean))].sort();
1822
+ $('companyList').replaceChildren(...companies.map(c => Object.assign(document.createElement('option'), { value: c })));
1823
+ const used = oas.flatMap(o => o.questions.map(q => q.topic.trim())).filter(Boolean);
1824
+ const all = [...new Map([...used, ...TOPICS].map(t => [topicKey(t), t])).values()];
1825
+ $('topicList').replaceChildren(...all.map(t => Object.assign(document.createElement('option'), { value: t })));
1826
+ }
1827
+ function qRow(q) {
1828
+ const row = document.createElement('div'); row.className = 'q-row';
1829
+ const topic = document.createElement('input'); topic.setAttribute('list', 'topicList'); topic.placeholder = 'Topic'; topic.value = q.topic; topic.setAttribute('aria-label', 'Question topic'); topic.dataset.k = 'topic';
1830
+ const name = document.createElement('input'); name.placeholder = 'Question (optional)'; name.value = q.name; name.setAttribute('aria-label', 'Question name'); name.dataset.k = 'name';
1831
+ const out = document.createElement('select'); out.setAttribute('aria-label', 'How it went'); out.dataset.k = 'outcome';
1832
+ OUTCOMES.forEach(([v, l]) => out.appendChild(Object.assign(document.createElement('option'), { value: v, textContent: l })));
1833
+ out.value = q.outcome;
1834
+ const del = document.createElement('button'); del.type = 'button'; del.className = 'btn btn-text btn-icon'; del.setAttribute('aria-label', 'Remove question');
1835
+ del.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>';
1836
+ del.onclick = () => { row.remove(); saveOA(); };
1837
+ row.append(topic, out, del, name);
1838
+ return row;
1839
+ }
1840
+ function openOA(id, from) {
1841
+ fillLists();
1842
+ let o = id && oas.find(x => x.id === id);
1843
+ if (!o) { o = { id: newId(), company: '', role: '', date: ymd(today), platform: '', score: '', questions: [{ topic: '', name: '', outcome: 'solved' }], well: '', wrong: '', next: '', createdAt: Date.now(), updatedAt: Date.now() }; }
1844
+ editingId = o.id;
1845
+ const f = $('oaForm');
1846
+ ['company', 'role', 'date', 'platform', 'score', 'well', 'wrong', 'next'].forEach(k => { f.elements[k].value = o[k] || ''; });
1847
+ $('qList').replaceChildren(...o.questions.map(qRow));
1848
+ $('oaTitle').textContent = o.company || 'New OA';
1849
+ $('oaSaved').classList.remove('on');
1850
+ $('oaDelete').hidden = !oas.some(x => x.id === o.id);
1851
+ if ($('oaPanel').hidden) { oaReturn = from || document.activeElement; slideDrawer($('oaPanel'), true); }
1852
+ f.elements.company.focus({ preventScroll: true });
1853
+ }
1854
+ function closeOA(instant) {
1855
+ const p = $('oaPanel');
1856
+ if (p.hidden && !p._anim) return;
1857
+ // An OA that was never given a company or any content is dropped instead of saved.
1858
+ const o = oas.find(x => x.id === editingId);
1859
+ if (o && !o.company.trim() && !o.questions.some(q => q.topic || q.name) && !o.well && !o.wrong && !o.next) { oas = oas.filter(x => x !== o); saveOAs(); }
1860
+ editingId = null;
1861
+ if (instant) p.hidden = true; else slideDrawer(p, false);
1862
+ if (!instant && oaReturn && document.contains(oaReturn)) oaReturn.focus({ preventScroll: true });
1863
+ if (view === 'oas') renderOAs();
1864
+ }
1865
+ function saveOA() {
1866
+ if (!editingId) return;
1867
+ const f = $('oaForm');
1868
+ const data = {};
1869
+ ['company', 'role', 'date', 'platform', 'score', 'well', 'wrong', 'next'].forEach(k => { data[k] = f.elements[k].value; });
1870
+ data.questions = [...$('qList').children].map(r => ({ topic: r.querySelector('[data-k=topic]').value, name: r.querySelector('[data-k=name]').value, outcome: r.querySelector('[data-k=outcome]').value }))
1871
+ .filter(q => q.topic.trim() || q.name.trim() || q.outcome !== 'solved');
1872
+ let o = oas.find(x => x.id === editingId);
1873
+ if (!o) { o = { id: editingId, createdAt: Date.now() }; oas.push(o); freshOaId = editingId; }
1874
+ Object.assign(o, data, { updatedAt: Date.now() });
1875
+ saveOAs();
1876
+ $('oaTitle').textContent = o.company || 'New OA';
1877
+ $('oaDelete').hidden = false;
1878
+ $('oaSaved').textContent = 'Saved';
1879
+ $('oaSaved').classList.add('on');
1880
+ clearTimeout(savedTimer); savedTimer = setTimeout(() => $('oaSaved').classList.remove('on'), 1600);
1881
+ renderOAs();
1882
+ }
1883
+ let saveTimer = null;
1884
+ $('oaForm').addEventListener('input', () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveOA, 250); });
1885
+ $('oaForm').addEventListener('change', () => { clearTimeout(saveTimer); saveOA(); });
1886
+ $('addQ').addEventListener('click', () => { const r = qRow({ topic: '', name: '', outcome: 'solved' }); $('qList').appendChild(r); r.querySelector('input').focus(); });
1887
+ $('oaClose').addEventListener('click', () => { clearTimeout(saveTimer); saveOA(); closeOA(); });
1888
+ $('oaDone').addEventListener('click', () => { clearTimeout(saveTimer); saveOA(); closeOA(); });
1889
+ $('oaDelete').addEventListener('click', () => {
1890
+ const o = oas.find(x => x.id === editingId);
1891
+ if (!o || !confirm(`Delete the ${o.company || 'untitled'} OA and its notes?`)) return;
1892
+ oas = oas.filter(x => x !== o); saveOAs(); editingId = null; closeOA();
1893
+ });
1894
+ $('newOaBtn').addEventListener('click', (e) => openOA(null, e.currentTarget));
1895
+ document.addEventListener('click', (e) => { if (e.target.closest('[data-new-oa]')) openOA(null, e.target.closest('[data-new-oa]')); });
1896
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('oaPanel').hidden && !openMenuBtn) { clearTimeout(saveTimer); saveOA(); closeOA(); } });
1897
+ addEventListener('hashchange', () => setView(location.hash === '#oas' ? 'oas' : 'apps'));
1898
+
1899
+ /* ---------- Auto-save to a file on disk (Chrome, Edge, Arc, Brave) ----------
1900
+ The person picks a JSON file once (e.g. in iCloud Drive). Every change rewrites it, so the data outlives
1901
+ the browser's own storage. The file uses the backup format, so Restore can read it too. The file handle
1902
+ lives in IndexedDB (handles can't go in localStorage). */
1903
+ const FS_OK = 'showSaveFilePicker' in window;
1904
+ const DIRTY_KEY = 'appfunnel.dirty.v1'; // changed locally since the last successful write
1905
+ const idb = (() => {
1906
+ let dbp;
1907
+ const db = () => dbp || (dbp = new Promise((res, rej) => { const r = indexedDB.open('justapply', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }));
1908
+ const tx = (mode, fn) => db().then(d => new Promise((res, rej) => { const t = d.transaction('kv', mode); const q = fn(t.objectStore('kv')); t.oncomplete = () => res(q && q.result); t.onerror = () => rej(t.error); }));
1909
+ return { get: k => tx('readonly', s => s.get(k)), set: (k, v) => tx('readwrite', s => s.put(v, k)), del: k => tx('readwrite', s => s.delete(k)) };
1910
+ })();
1911
+ let fileHandle = null, fileState = 'off', fileSavedAt = null, writeTimer = null, writing = Promise.resolve();
1912
+ // fileState: 'off' (no file) | 'on' (saving) | 'paused' (needs permission again) | 'error'
1913
+
1914
+ const snapshotData = () => ({ app: 'JustApply', version: 2, savedAt: new Date().toISOString(), files: files.map(f => ({ name: f.name, text: f.text, at: f.at })), overrides, oas });
1915
+ async function permitted(h, ask) {
1916
+ if (!h.queryPermission) return true; // e.g. origin-private files used in tests
1917
+ const o = { mode: 'readwrite' };
1918
+ if ((await h.queryPermission(o)) === 'granted') return true;
1919
+ return ask ? (await h.requestPermission(o)) === 'granted' : false;
1920
+ }
1921
+ function dataChanged() {
1922
+ try { localStorage.setItem(DIRTY_KEY, '1'); } catch {}
1923
+ if (fileState !== 'on') return;
1924
+ clearTimeout(writeTimer);
1925
+ writeTimer = setTimeout(writeFile, 700);
1926
+ }
1927
+ function writeFile() {
1928
+ if (!fileHandle || fileState !== 'on') return writing;
1929
+ writing = writing.then(async () => {
1930
+ try {
1931
+ const w = await fileHandle.createWritable();
1932
+ await w.write(JSON.stringify(snapshotData()));
1933
+ await w.close();
1934
+ fileSavedAt = new Date();
1935
+ try { localStorage.removeItem(DIRTY_KEY); } catch {}
1936
+ } catch (e) {
1937
+ fileState = (e && e.name === 'NotAllowedError') ? 'paused' : 'error';
1938
+ }
1939
+ renderFileStatus();
1940
+ });
1941
+ return writing;
1942
+ }
1943
+ // Bring data from the file into the app. mode 'replace' trusts the file; 'merge' keeps both sides.
1944
+ function absorb(data, mode) {
1945
+ if (!data || !Array.isArray(data.files)) return;
1946
+ if (mode === 'replace') {
1947
+ files = []; merged = null; overrides = data.overrides || []; oas = data.oas || [];
1948
+ } else {
1949
+ (data.overrides || []).forEach(o => { if (!overrides.some(x => sharesKey(x.keys, o.keys))) overrides.push(o); });
1950
+ (data.oas || []).forEach(o => { const cur = oas.find(x => x.id === o.id); if (!cur) oas.push(o); else if ((o.updatedAt || 0) > (cur.updatedAt || 0)) Object.assign(cur, o); });
1951
+ }
1952
+ store.set(OVERRIDE_KEY, overrides); saveOAs();
1953
+ if (data.files.length) loadTexts(data.files, true);
1954
+ else { if (mode === 'replace') { [STORE_KEY, SNAP_KEY, DIFF_KEY].forEach(k => store.del(k)); } render(); }
1955
+ if (view === 'oas') renderOAs();
1956
+ }
1957
+ async function readFile(h) {
1958
+ const f = await h.getFile();
1959
+ const t = await f.text();
1960
+ if (!t.trim()) return null;
1961
+ return JSON.parse(t);
1962
+ }
1963
+ async function connect(h, how) {
1964
+ fileHandle = h; await idb.set('file', h);
1965
+ fileState = 'on';
1966
+ if (how === 'new') { await writeFile(); }
1967
+ else {
1968
+ let data = null;
1969
+ try { data = await readFile(h); } catch { alert("That file isn't JustApply data."); fileState = 'off'; fileHandle = null; await idb.del('file'); renderFileStatus(); return; }
1970
+ const dirty = localStorage.getItem(DIRTY_KEY) === '1';
1971
+ // Reconnecting with nothing changed here: the file is the truth. Otherwise keep both and write the union back.
1972
+ absorb(data, how === 'reconnect' && !dirty ? 'replace' : 'merge');
1973
+ await writeFile();
1974
+ }
1975
+ renderFileStatus();
1976
+ }
1977
+ async function startAutoSave() {
1978
+ closeMenu();
1979
+ try {
1980
+ const h = await showSaveFilePicker({ suggestedName: 'JustApply.json', types: [{ description: 'JustApply data', accept: { 'application/json': ['.json'] } }] });
1981
+ await connect(h, 'new');
1982
+ } catch (e) { if (e.name !== 'AbortError') alert("Couldn't start auto-saving to that file."); }
1983
+ }
1984
+ async function openDataFile() {
1985
+ closeMenu();
1986
+ try {
1987
+ const [h] = await showOpenFilePicker({ types: [{ description: 'JustApply data', accept: { 'application/json': ['.json'] } }] });
1988
+ if (!(await permitted(h, true))) return;
1989
+ await connect(h, 'open');
1990
+ } catch (e) { if (e.name !== 'AbortError') alert("Couldn't open that file."); }
1991
+ }
1992
+ async function stopAutoSave() {
1993
+ closeMenu();
1994
+ fileHandle = null; fileState = 'off'; fileSavedAt = null; await idb.del('file');
1995
+ renderFileStatus();
1996
+ }
1997
+ async function reconnect() {
1998
+ if (!fileHandle) return;
1999
+ if (await permitted(fileHandle, true)) await connect(fileHandle, 'reconnect');
2000
+ }
2001
+ function renderFileStatus() {
2002
+ const name = fileHandle ? fileHandle.name : '';
2003
+ $('autoSaveBtn').hidden = !FS_OK || fileState !== 'off';
2004
+ $('openFileBtn').hidden = !FS_OK || fileState !== 'off';
2005
+ $('stopFileBtn').hidden = fileState === 'off';
2006
+ const meta = $('fileMeta');
2007
+ meta.textContent = !FS_OK ? 'Auto-save to a file needs Chrome, Edge, Arc or Brave.'
2008
+ : fileState === 'off' ? 'Not auto-saving to a file. Your data is only in this browser.'
2009
+ : fileState === 'on' ? `Auto-saving to ${name}` + (fileSavedAt ? ` · saved ${fileSavedAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}` : '')
2010
+ : fileState === 'paused' ? `Auto-save to ${name} is paused.` : `Couldn't write to ${name}.`;
2011
+ const n = $('fileNotice');
2012
+ n.hidden = !(fileState === 'paused' || fileState === 'error');
2013
+ $('fileNoticeText').textContent = fileState === 'error' ? `Couldn't write to ${name}. It may have been moved or deleted.` : `Auto-save to ${name} is paused until you allow it again.`;
2014
+ $('fileNoticeBtn').textContent = fileState === 'error' ? 'Choose a file' : 'Reconnect';
2015
+ }
2016
+ $('autoSaveBtn').addEventListener('click', startAutoSave);
2017
+ $('openFileBtn').addEventListener('click', openDataFile);
2018
+ $('stopFileBtn').addEventListener('click', stopAutoSave);
2019
+ $('fileNoticeBtn').addEventListener('click', () => fileState === 'error' ? startAutoSave() : reconnect());
2020
+ // On launch: pick the saved file back up. Chrome remembers "Allow on every visit"; otherwise this waits for Reconnect.
2021
+ (async () => {
2022
+ renderFileStatus();
2023
+ if (!FS_OK) return;
2024
+ try {
2025
+ const h = await idb.get('file');
2026
+ if (!h) return;
2027
+ fileHandle = h;
2028
+ if (await permitted(h, false)) await connect(h, 'reconnect');
2029
+ else { fileState = 'paused'; renderFileStatus(); }
2030
+ } catch { /* no saved file */ }
2031
+ })();
2032
+ window.__dataChanged = dataChanged;
2033
+
2034
+ // Ask the browser not to evict this app's storage (granted automatically for an installed app).
2035
+ navigator.storage?.persist?.().catch(() => {});
2036
+ // Offline + installable when served over http(s); a file:// page skips this.
2037
+ if ('serviceWorker' in navigator && location.protocol.startsWith('http')) navigator.serviceWorker.register('sw.js').catch(() => {});
2038
+ const saved = store.get(STORE_KEY);
2039
+ if (Array.isArray(saved) && saved.length) loadTexts(saved, false); else render();
2040
+ setView(location.hash === '#oas' ? 'oas' : 'apps');
2041
+ })();
2042
+ </script>
2043
+ </body>
2044
+ </html>