Skip to content

Commit 6adcc80

Browse files
committed
feat: share trips through encoded links
Let users copy a self-contained trips URL that validates and restores shared data before any locally saved edits.
1 parent 9228169 commit 6adcc80

3 files changed

Lines changed: 104 additions & 18 deletions

File tree

index.html

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,23 +149,24 @@
149149
place-items: center;
150150
flex: none;
151151
}
152-
.github-link {
152+
.header-action {
153153
position: relative;
154154
width: 34px;
155155
height: 34px;
156156
display: grid;
157157
place-items: center;
158158
flex: none;
159+
padding: 0;
159160
color: var(--text-primary);
160161
background: var(--field);
161162
border: 1px solid var(--border);
162163
border-radius: 50%;
163164
text-decoration: none;
164165
}
165-
.github-link:hover {
166+
.header-action:hover {
166167
border-color: var(--axis);
167168
}
168-
.github-link:focus-visible {
169+
.header-action:focus-visible {
169170
outline: 2px solid var(--series-1);
170171
outline-offset: 1px;
171172
}
@@ -174,7 +175,16 @@
174175
height: 17px;
175176
fill: currentColor;
176177
}
177-
.github-link::after {
178+
.share-button svg {
179+
width: 17px;
180+
height: 17px;
181+
fill: none;
182+
stroke: currentColor;
183+
stroke-width: 1.8;
184+
stroke-linecap: round;
185+
stroke-linejoin: round;
186+
}
187+
.header-action::after {
178188
content: attr(aria-label);
179189
position: absolute;
180190
top: calc(100% + 6px);
@@ -194,8 +204,8 @@
194204
visibility: hidden;
195205
transition: opacity 80ms ease 100ms, visibility 0s linear 180ms;
196206
}
197-
.github-link:hover::after,
198-
.github-link:focus-visible::after {
207+
.header-action:hover::after,
208+
.header-action:focus-visible::after {
199209
opacity: 1;
200210
visibility: visible;
201211
transition-delay: 100ms;
@@ -792,7 +802,7 @@ <h1>UK ILR absence tracker</h1>
792802
</svg>
793803
</button>
794804
<a
795-
class="github-link"
805+
class="header-action github-link"
796806
href="https://github.com/indradhanush/ilr"
797807
target="_blank"
798808
rel="noopener noreferrer"
@@ -802,6 +812,19 @@ <h1>UK ILR absence tracker</h1>
802812
<path d="M12 .7a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.2.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.6.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.9 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.5.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.8 5.4-5.5 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6A11.5 11.5 0 0 0 12 .7Z"></path>
803813
</svg>
804814
</a>
815+
<button
816+
class="header-action share-button"
817+
id="share"
818+
type="button"
819+
aria-label="Copy a shareable trips link"
820+
>
821+
<svg viewBox="0 0 24 24" aria-hidden="true">
822+
<circle cx="18" cy="5" r="2.5"></circle>
823+
<circle cx="6" cy="12" r="2.5"></circle>
824+
<circle cx="18" cy="19" r="2.5"></circle>
825+
<path d="m8.2 10.8 7.6-4.5M8.2 13.2l7.6 4.5"></path>
826+
</svg>
827+
</button>
805828
</div>
806829
</header>
807830

src/app.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ interface Domain {
219219
* the generated initial data; Reset discards edits and returns to it.
220220
*/
221221
const DATA_KEY = "ilr.data.v1";
222+
const SHARE_PARAM = "trips";
222223

223224
function saveData(): void {
224225
const payload = JSON.stringify({ visaValidFrom, firstArrivalOn, trips });
@@ -1023,6 +1024,59 @@ interface Domain {
10231024
};
10241025
}
10251026

1027+
function applyData(data: ILRData): void {
1028+
visaValidFrom = data.visaValidFrom || "";
1029+
firstArrivalOn = data.firstArrivalOn || "";
1030+
trips = (data.trips || []).map((trip) => ({
1031+
depart: trip.depart,
1032+
ret: trip.return,
1033+
place: trip.place || "",
1034+
reason: trip.reason || "",
1035+
}));
1036+
}
1037+
1038+
function restoreSharedData(): boolean {
1039+
const raw = new URL(window.location.href).searchParams.get(SHARE_PARAM);
1040+
if (raw === null) return false;
1041+
try {
1042+
const data = parseImportedData(JSON.parse(raw));
1043+
if (!data) return false;
1044+
applyData(data);
1045+
saveData();
1046+
return true;
1047+
} catch {
1048+
return false;
1049+
}
1050+
}
1051+
1052+
function shareUrl(): string {
1053+
const url = new URL(window.location.href);
1054+
url.searchParams.set(SHARE_PARAM, JSON.stringify(exportData()));
1055+
url.hash = "";
1056+
return url.toString();
1057+
}
1058+
1059+
async function copyText(text: string): Promise<boolean> {
1060+
try {
1061+
if (navigator.clipboard) {
1062+
await navigator.clipboard.writeText(text);
1063+
return true;
1064+
}
1065+
} catch { /* fall through for local files and denied permission */ }
1066+
1067+
const textarea = document.createElement("textarea");
1068+
textarea.value = text;
1069+
textarea.setAttribute("readonly", "");
1070+
textarea.style.position = "fixed";
1071+
textarea.style.opacity = "0";
1072+
document.body.appendChild(textarea);
1073+
textarea.select();
1074+
let copied = false;
1075+
try { copied = document.execCommand("copy"); } catch { /* unsupported */ }
1076+
textarea.remove();
1077+
return copied;
1078+
}
1079+
10261080
el("export").addEventListener("click", () => {
10271081
const data = exportData();
10281082
const txt = JSON.stringify(data, null, 2) + "\n";
@@ -1037,6 +1091,11 @@ interface Domain {
10371091
flash("exported trips.json");
10381092
});
10391093

1094+
el("share").addEventListener("click", async () => {
1095+
const copied = await copyText(shareUrl());
1096+
flash(copied ? "Share link copied" : "Could not copy share link");
1097+
});
1098+
10401099
const importFile = el<HTMLInputElement>("import-file");
10411100
el("import").addEventListener("click", () => importFile.click());
10421101
importFile.addEventListener("change", async () => {
@@ -1046,14 +1105,7 @@ interface Domain {
10461105
try {
10471106
const data = parseImportedData(JSON.parse(await file.text()));
10481107
if (!data) throw new Error("missing trips array");
1049-
visaValidFrom = data.visaValidFrom || "";
1050-
firstArrivalOn = data.firstArrivalOn || "";
1051-
trips = (data.trips || []).map((trip) => ({
1052-
depart: trip.depart,
1053-
ret: trip.return,
1054-
place: trip.place || "",
1055-
reason: trip.reason || "",
1056-
}));
1108+
applyData(data);
10571109
saveData();
10581110
render();
10591111
flash(`imported ${file.name}`);
@@ -1160,7 +1212,7 @@ interface Domain {
11601212
initChartSelectionKeyboard();
11611213
initChartScrollVisibility();
11621214
loadFromFile();
1163-
restoreData();
1215+
if (!restoreSharedData()) restoreData();
11641216
restoreSelectedWindow();
11651217
render();
11661218
watchPlotHeight();

test/app.test.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,22 @@ test("theme toggle uses icons and persists the selected theme", () => {
117117
});
118118

119119
test("header links to the GitHub repository beside the theme toggle", () => {
120-
assert.match(indexSource, /<div class="header-actions">[\s\S]*?id="theme-toggle"[\s\S]*?class="github-link"/);
120+
assert.match(indexSource, /<div class="header-actions">[\s\S]*?id="theme-toggle"[\s\S]*?class="header-action github-link"/);
121121
assert.match(indexSource, /href="https:\/\/github\.com\/indradhanush\/ilr"/);
122122
assert.match(indexSource, /aria-label="Found it useful\? Leave a star on GitHub"/);
123123
assert.match(indexSource, /target="_blank"\s+rel="noopener noreferrer"/);
124-
assert.match(indexSource, /\.github-link::after \{[\s\S]*?content: attr\(aria-label\);[\s\S]*?transition: opacity 80ms ease 100ms/);
124+
assert.match(indexSource, /\.header-action::after \{[\s\S]*?content: attr\(aria-label\);[\s\S]*?transition: opacity 80ms ease 100ms/);
125+
});
126+
127+
test("share button copies export data in a URL that restores automatically", () => {
128+
assert.match(indexSource, /class="header-action share-button"\s+id="share"[\s\S]*?aria-label="Copy a shareable trips link"/);
129+
assert.match(appSource, /const SHARE_PARAM = "trips";/);
130+
assert.match(appSource, /url\.searchParams\.set\(SHARE_PARAM, JSON\.stringify\(exportData\(\)\)\);/);
131+
assert.match(appSource, /navigator\.clipboard\.writeText\(text\)/);
132+
assert.match(appSource, /document\.execCommand\("copy"\)/);
133+
assert.match(appSource, /el\("share"\)\.addEventListener\("click", async \(\) =>/);
134+
assert.match(appSource, /parseImportedData\(JSON\.parse\(raw\)\)[\s\S]*?applyData\(data\);[\s\S]*?saveData\(\);/);
135+
assert.match(appSource, /loadFromFile\(\);\s*if \(!restoreSharedData\(\)\) restoreData\(\);/);
125136
});
126137

127138
test("mobile header keeps the theme toggle beside the absence guidance", () => {

0 commit comments

Comments
 (0)