diff --git a/.gitignore b/.gitignore
index 6cc27d3..8d98727 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,6 @@ temp/
.env.local
test-data*.json
*.local
+backend/*.sqlite3
+backend/*.sqlite3-*
+backend/.venv/
diff --git a/README.en.md b/README.en.md
index 4ea3cdd..abe9a29 100644
--- a/README.en.md
+++ b/README.en.md
@@ -25,9 +25,9 @@ It is especially relevant for:
- advisors, case workers or professionals reviewing reports
- environments where data minimization and local storage matter
-Arbeidspuls is built as a static web app/PWA. It can normally be hosted as ordinary static files.
+Arbeidspuls is built as a web app/PWA that can normally be hosted as ordinary static files. Secure email sharing is an optional add-on that requires a small FastAPI backend.
-It normally does not require:
+The basic local-only flow normally does not require:
- a server database
- login
@@ -35,7 +35,7 @@ It normally does not require:
- a production Node process
- central storage of sensitive reports
-The user owns their data locally in the browser and actively shares it by exporting a JSON file.
+The user owns their data locally in the browser and actively shares it by exporting a JSON file or creating an encrypted one-time link.
---
@@ -213,7 +213,7 @@ https://arbeidspuls.rolfsvaag.no/evaluator
3. Choose the JSON file
4. Give the user a name or internal identifier
-The data is stored locally in the advisor's browser. Arbeidspuls does not send the reports to a central server.
+The data is stored locally in the advisor's browser after import. If the user shares by email link, the server only handles a temporary encrypted copy it cannot read.
### Review reports
@@ -256,23 +256,26 @@ This is a technical check. It is not a legal guarantee and does not prove the me
## Privacy and local storage
-Arbeidspuls stores data locally in the browser on the device being used.
+Arbeidspuls stores data locally in the browser on the device being used by default. Normal JSON export/import remains local-only.
+
+If the user chooses **Send by email**, the selected export is encrypted in the browser before it is uploaded to the Arbeidspuls server as a temporary one-time share. The server does not receive the decryption key and cannot read the report data. The key only exists in the URL fragment in the share link.
This means:
- reports are not automatically sent to a server
-- the standard app uses no central database
+- normal JSON export/import uses no central database
- data remains on the user's device/browser
- clearing browser data may delete reports
- changing device or browser requires export/import
+- encrypted email shares are deleted after the first successful import or automatically after 7 days
-The user must export JSON to share data with an advisor or move data to another device.
+After deletion, only minimal share metadata is retained: export ID, creation time, deletion time and deletion reason.
---
## Technical overview
-Arbeidspuls is a static React/Vite app with local browser storage.
+Arbeidspuls is a React/Vite app with local browser storage and an optional FastAPI backend for encrypted one-time shares.
Typical production build:
@@ -289,6 +292,10 @@ For an SPA, the web server should fall back to `index.html`, for example in Ngin
location / {
try_files $uri $uri/ /index.html;
}
+
+location /api/share/ {
+ proxy_pass http://127.0.0.1:8000/api/share/;
+}
```
### Technology
@@ -301,11 +308,12 @@ location / {
- local browser storage
- JSON import/export
- local advisor view
+- optional FastAPI/SQLite backend for encrypted one-time sharing
- Norwegian/English UI
### Production
-Arbeidspuls normally only requires static hosting. Running a Node server in production is not required.
+Arbeidspuls can still run as a purely static app if secure email sharing is not used. The public demo with **Send by email** requires the FastAPI backend, HTTPS, SPA fallback for `/s` and a reverse proxy from `/api/share` to the backend.
---
diff --git a/README.md b/README.md
index d4a0e60..ba49157 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,9 @@ Tjenesten er særlig relevant for:
- veiledere, rådgivere eller fagpersoner som skal gjennomgå rapporter
- miljøer der dataminimering og lokal lagring er viktig
-Arbeidspuls er bygget som en statisk webapp/PWA. Det betyr at den normalt kan hostes som vanlige statiske filer.
+Arbeidspuls er bygget som en webapp/PWA som normalt kan hostes som vanlige statiske filer. Sikker e-postdeling er et valgfritt tillegg som krever en liten FastAPI-backend.
-Den krever normalt ikke:
+Den grunnleggende local-only-flyten krever normalt ikke:
- serverdatabase
- innlogging
@@ -35,7 +35,7 @@ Den krever normalt ikke:
- Node-prosess i produksjon
- sentral lagring av sensitive rapporter
-Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å eksportere en JSON-fil.
+Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å eksportere en JSON-fil eller opprette en kryptert engangslenke.
---
@@ -213,7 +213,7 @@ https://arbeidspuls.rolfsvaag.no/evaluator
3. Velg JSON-filen
4. Gi brukeren et navn eller en intern identifikator
-Dataene lagres lokalt i veilederens nettleser. Arbeidspuls sender ikke rapportene til en sentral server.
+Dataene lagres lokalt i veilederens nettleser etter import. Hvis brukeren deler via e-postlenke, håndterer serveren bare en midlertidig kryptert kopi som den ikke kan lese.
### Gjennomgå rapporter
@@ -256,23 +256,26 @@ Dette er en teknisk kontroll. Det er ikke en juridisk garanti og sier ikke noe o
## Personvern og lokal lagring
-Arbeidspuls lagrer data lokalt i nettleseren på enheten som brukes.
+Arbeidspuls lagrer data lokalt i nettleseren på enheten som brukes som hovedregel. Vanlig JSON-eksport/import er fortsatt local-only.
+
+Hvis brukeren velger **Send via e-post**, krypteres valgt eksport i nettleseren før den lastes opp til Arbeidspuls-serveren som en midlertidig engangsdeling. Serveren får ikke dekrypteringsnøkkelen og kan ikke lese rapportdataene. Nøkkelen ligger bare i URL-fragmentet i delingslenken.
Det betyr:
- rapporter sendes ikke automatisk til en server
-- standardappen bruker ingen sentral database
+- vanlig JSON-eksport/import bruker ingen sentral database
- data blir liggende på brukerens enhet/nettleser
- sletting av nettleserdata kan slette rapportene
- bytte av enhet eller nettleser krever eksport/import
+- krypterte e-postdelinger slettes etter første vellykkede import eller automatisk etter 7 dager
-Brukeren må selv eksportere JSON dersom data skal deles med veileder eller tas med til en annen enhet.
+Etter sletting beholdes kun minimal metadata for delingen: eksport-id, opprettelsestidspunkt, slettetidspunkt og slettegrunn.
---
## Teknisk oversikt
-Arbeidspuls er en statisk React/Vite-app med lokal nettleserlagring.
+Arbeidspuls er en React/Vite-app med lokal nettleserlagring og en valgfri FastAPI-backend for krypterte engangsdelinger.
Typisk produksjonsbygg:
@@ -289,6 +292,10 @@ For en SPA bør webserveren ha fallback til `index.html`, for eksempel i Nginx:
location / {
try_files $uri $uri/ /index.html;
}
+
+location /api/share/ {
+ proxy_pass http://127.0.0.1:8000/api/share/;
+}
```
### Teknologi
@@ -301,11 +308,12 @@ location / {
- lokal lagring i nettleser
- JSON import/eksport
- lokal veiledervisning
+- valgfri FastAPI/SQLite-backend for kryptert engangsdeling
- norsk/engelsk UI
### Produksjon
-Arbeidspuls krever normalt bare statisk hosting. Det er ikke nødvendig å kjøre en Node-server i produksjon.
+Arbeidspuls kan fortsatt kjøres som ren statisk app hvis sikker e-postdeling ikke brukes. Offentlig demo med **Send via e-post** krever FastAPI-backend, HTTPS, SPA fallback for `/s` og reverse proxy fra `/api/share` til backend.
---
diff --git a/backend/app.py b/backend/app.py
new file mode 100644
index 0000000..300afde
--- /dev/null
+++ b/backend/app.py
@@ -0,0 +1,201 @@
+from __future__ import annotations
+
+import base64
+import os
+import re
+import sqlite3
+from contextlib import contextmanager
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+
+DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3"))
+EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7"))
+MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
+RATE_LIMIT_PER_HOUR = 10
+ALLOWED_ORIGINS = [
+ "https://arbeidspuls.rolfsvaag.no",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+]
+
+app = FastAPI(title="Arbeidspuls secure share API")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[origin.strip() for origin in os.environ.get("ARBEIDSPULS_CORS_ORIGINS", ",".join(ALLOWED_ORIGINS)).split(",")],
+ allow_credentials=False,
+ allow_methods=["POST", "GET", "OPTIONS"],
+ allow_headers=["Content-Type"],
+)
+
+rate_bucket: dict[str, list[datetime]] = {}
+
+
+class ShareCreateRequest(BaseModel):
+ ciphertext: str = Field(min_length=1)
+ iv: str = Field(min_length=1)
+ share_schema: str = Field(alias="schema", min_length=1, max_length=80)
+
+
+class ShareCreateResponse(BaseModel):
+ export_id: str
+ expires_at: str
+
+
+@contextmanager
+def connect():
+ DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ db = sqlite3.connect(DATABASE_PATH)
+ db.row_factory = sqlite3.Row
+ try:
+ yield db
+ db.commit()
+ finally:
+ db.close()
+
+
+def utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def iso(value: datetime) -> str:
+ return value.isoformat().replace("+00:00", "Z")
+
+
+def init_db() -> None:
+ with connect() as db:
+ db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS active_shares (
+ export_id TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ ciphertext TEXT NOT NULL,
+ iv TEXT NOT NULL,
+ schema TEXT NOT NULL
+ )
+ """
+ )
+ db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS share_audit (
+ export_id TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL,
+ deleted_at TEXT NOT NULL,
+ delete_reason TEXT NOT NULL
+ )
+ """
+ )
+
+
+def decoded_size(value: str) -> int:
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
+ raise HTTPException(status_code=400, detail="Invalid base64url payload.")
+ padded = value.replace("-", "+").replace("_", "/") + "=" * (-len(value) % 4)
+ try:
+ return len(base64.b64decode(padded, validate=True))
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail="Invalid base64url payload.") from exc
+
+
+def cleanup_expired() -> None:
+ now = iso(utc_now())
+ with connect() as db:
+ rows = db.execute("SELECT export_id, created_at FROM active_shares WHERE expires_at <= ?", (now,)).fetchall()
+ for row in rows:
+ mark_deleted(db, row["export_id"], row["created_at"], "expired")
+
+
+def mark_deleted(db: sqlite3.Connection, export_id: str, created_at: str, reason: str) -> None:
+ db.execute("DELETE FROM active_shares WHERE export_id = ?", (export_id,))
+ db.execute(
+ """
+ INSERT OR REPLACE INTO share_audit (export_id, created_at, deleted_at, delete_reason)
+ VALUES (?, ?, ?, ?)
+ """,
+ (export_id, created_at, iso(utc_now()), reason),
+ )
+
+
+def client_ip(request: Request) -> str:
+ forwarded = request.headers.get("x-forwarded-for", "")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
+def assert_rate_limit(request: Request) -> None:
+ ip = client_ip(request)
+ cutoff = utc_now() - timedelta(hours=1)
+ recent = [created for created in rate_bucket.get(ip, []) if created > cutoff]
+ if len(recent) >= RATE_LIMIT_PER_HOUR:
+ raise HTTPException(status_code=429, detail="Rate limit exceeded.")
+ recent.append(utc_now())
+ rate_bucket[ip] = recent
+
+
+@app.on_event("startup")
+def startup() -> None:
+ init_db()
+ cleanup_expired()
+
+
+@app.post("/api/share", response_model=ShareCreateResponse)
+def create_share(payload: ShareCreateRequest, request: Request) -> dict[str, str]:
+ cleanup_expired()
+ assert_rate_limit(request)
+ if decoded_size(payload.ciphertext) > MAX_PAYLOAD_BYTES:
+ raise HTTPException(status_code=413, detail="Payload is too large.")
+ if decoded_size(payload.iv) != 12:
+ raise HTTPException(status_code=400, detail="Invalid IV size.")
+
+ now = utc_now()
+ export_id = str(uuid4())
+ expires_at = now + timedelta(days=EXPIRY_DAYS)
+ with connect() as db:
+ db.execute(
+ """
+ INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (export_id, iso(now), iso(expires_at), payload.ciphertext, payload.iv, payload.share_schema),
+ )
+ return {"export_id": export_id, "expires_at": iso(expires_at)}
+
+
+@app.get("/api/share/{export_id}")
+def get_share(export_id: str) -> dict[str, Any]:
+ cleanup_expired()
+ with connect() as db:
+ row = db.execute("SELECT * FROM active_shares WHERE export_id = ?", (export_id,)).fetchone()
+ if row:
+ return {
+ "status": "available",
+ "export_id": row["export_id"],
+ "ciphertext": row["ciphertext"],
+ "iv": row["iv"],
+ "schema": row["schema"],
+ "expires_at": row["expires_at"],
+ }
+ audit = db.execute("SELECT delete_reason FROM share_audit WHERE export_id = ?", (export_id,)).fetchone()
+ if audit and audit["delete_reason"] == "expired":
+ return {"status": "expired", "export_id": export_id}
+ if audit:
+ return {"status": "deleted", "export_id": export_id}
+ raise HTTPException(status_code=404, detail="Share not found.")
+
+
+@app.post("/api/share/{export_id}/confirm-import")
+def confirm_import(export_id: str) -> dict[str, str]:
+ cleanup_expired()
+ with connect() as db:
+ row = db.execute("SELECT export_id, created_at FROM active_shares WHERE export_id = ?", (export_id,)).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Share is not available.")
+ mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient")
+ return {"status": "deleted"}
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..3fb50f0
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,2 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
diff --git a/docs/TERMS.en.md b/docs/TERMS.en.md
index a0e4c0c..0ef94b4 100644
--- a/docs/TERMS.en.md
+++ b/docs/TERMS.en.md
@@ -15,8 +15,10 @@ Summary:
- The demo is provided as-is and as-available.
- Availability, functionality and data persistence are not guaranteed.
- Reports are normally stored locally in the user's browser/device.
+- If the user chooses Send by email, the selected export is encrypted in the browser before temporary upload to the Arbeidspuls server. The server does not receive the decryption key and cannot read the report data.
+- Encrypted one-time shares are deleted after the first successful import or automatically after 7 days. After deletion, only minimal metadata is retained: export ID, creation time, deletion time and deletion reason.
- Users are responsible for exporting/backing up their own data.
-- Sharing with an advisor requires exporting and sending a JSON file.
+- Sharing with an advisor can be done by exporting and sending a JSON file, or by creating an encrypted one-time link with Send by email.
- Organizations, public bodies, healthcare providers, companies and professional actors may not make the public demo part of their ordinary workflow without a special license.
- Arbeidspuls must not be used as the sole basis for medical, legal, financial, public or employment-related decisions.
diff --git a/docs/TERMS.md b/docs/TERMS.md
index f99a288..682d5c8 100644
--- a/docs/TERMS.md
+++ b/docs/TERMS.md
@@ -46,6 +46,8 @@ Tjenesten kan endres, begrenses, oppdateres eller avsluttes uten forvarsel.
Arbeidspuls lagrer normalt rapporter lokalt i nettleseren på enheten du bruker.
+Hvis du velger **Send via e-post**, krypteres valgt eksport i nettleseren før den lastes opp til Arbeidspuls-serveren som en midlertidig engangsdeling. Serveren får ikke dekrypteringsnøkkelen og kan ikke lese rapportdataene.
+
Det betyr at:
- rapporter ikke automatisk sendes til en sentral server;
@@ -54,17 +56,19 @@ Det betyr at:
- bytte av enhet eller nettleser krever eksport/import;
- du selv er ansvarlig for backup hvis rapportene er viktige.
+Krypterte engangsdelinger slettes etter første vellykkede import hos mottaker eller automatisk etter 7 dager. Etter sletting beholdes kun minimal metadata: eksport-id, opprettelsestidspunkt, slettetidspunkt og slettegrunn.
+
Eksporter JSON-fil jevnlig dersom du vil ta vare på dataene.
---
## 4. Deling med veileder
-Hvis en veileder eller annen person skal gjennomgå rapportene dine, må du eksportere en JSON-fil og dele den med vedkommende.
+Hvis en veileder eller annen person skal gjennomgå rapportene dine, kan du eksportere en JSON-fil og dele den med vedkommende, eller bruke Send via e-post for å opprette en kryptert engangslenke.
-JSON-filen kan inneholde helse- og funksjonsrelaterte opplysninger. Del den bare med personer som faktisk skal ha tilgang.
+JSON-filen eller engangslenken kan inneholde tilgang til helse- og funksjonsrelaterte opplysninger. Del den bare med personer som faktisk skal ha tilgang.
-Du er selv ansvarlig for hvordan du deler eksporterte filer.
+Du er selv ansvarlig for hvordan du deler eksporterte filer og engangslenker.
---
diff --git a/package-lock.json b/package-lock.json
index 1dcbd99..974934b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "arbeidspuls",
- "version": "1.1.2",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arbeidspuls",
- "version": "1.1.2",
+ "version": "1.2.0",
"dependencies": {
"lucide-react": "^0.475.0",
"react": "^19.0.0",
diff --git a/package.json b/package.json
index 26ace04..97b0012 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "arbeidspuls",
- "version": "1.1.2",
+ "version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/public/service-worker.js b/public/service-worker.js
index 3f50d4f..e8533dc 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = "arbeidspuls-v7";
+const CACHE_NAME = "arbeidspuls-v8";
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
self.addEventListener("install", (event) => {
diff --git a/src/App.tsx b/src/App.tsx
index ef0361b..c6ab2d1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -62,11 +62,20 @@ import {
translateVerificationStatus,
useI18n
} from "./i18n";
+import {
+ confirmSecureShareImport,
+ createSecureShare,
+ decryptSecureSharePayload,
+ fetchSecureShare,
+ parseShareRequestFromLocation
+} from "./secureShare";
import {
createFollowup,
createReport,
deleteAllReports,
deleteReport,
+ assertValidExportRange,
+ buildExportFilename,
ensureDemoData,
exportReports,
getLatestWorkReport,
@@ -80,6 +89,7 @@ import {
updateFollowup,
updateReport
} from "./storage";
+import type { ExportRange } from "./storage";
import type {
Accommodation,
DelayedFollowup,
@@ -105,7 +115,7 @@ type LineVisibility = "normal" | "dim" | "hidden";
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
-const APP_VERSION = "1.1";
+const APP_VERSION = "1.2";
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
@@ -131,6 +141,9 @@ function App() {
function AppRoutes() {
const { t } = useI18n();
+ if (window.location.pathname.startsWith("/s")) {
+ return ;
+ }
if (window.location.pathname.startsWith("/evaluator") || window.location.pathname.startsWith("/veileder")) {
return ;
}
@@ -271,7 +284,15 @@ function NavButton({
);
}
-function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoModeChange: (enabled: boolean) => void }) {
+function AppFooter({
+ demoMode,
+ onDemoModeChange,
+ showDemoToggle = true
+}: {
+ demoMode: boolean;
+ onDemoModeChange: (enabled: boolean) => void;
+ showDemoToggle?: boolean;
+}) {
const { language, setLanguage, t } = useI18n();
const baseYear = 2026;
const currentYear = new Date().getFullYear();
@@ -307,9 +328,11 @@ function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoMo
{t("footer.sourceCode", { version: APP_VERSION })}
-
+ {showDemoToggle && (
+
+ )}
);
@@ -1162,7 +1185,6 @@ function PeriodControls({ period, onChange }: { period: TrendPeriod; onChange: (
aria-label={`${t("overview.fromDate")} ${datePlaceholder}`}
onChange={(event) => onChange({ ...period, preset: "custom", startDate: event.target.value })}
/>
- {datePlaceholder}
@@ -1231,7 +1252,7 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
];
const [periodStart, periodEnd] = periodBounds(period);
- const minTime = periodStart ?? Math.min(...times);
+ const minTime = graphDomainStart(times, periodStart);
const maxTime = periodEnd ?? Math.max(...times);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
@@ -1461,7 +1482,7 @@ function FullscreenGraphModal({
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
];
const [periodStart, periodEnd] = periodBounds(period);
- const minTime = periodStart ?? (times.length ? Math.min(...times) : Date.now());
+ const minTime = times.length ? graphDomainStart(times, periodStart) : graphDomainStart([Date.now()], periodStart);
const maxTime = periodEnd ?? (times.length ? Math.max(...times) : minTime + 1);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
@@ -1865,16 +1886,8 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
const [importMessage, setImportMessage] = useState("");
-
- const download = () => {
- const blob = new Blob([exportReports(demoMode)], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-rapporter-${new Date().toISOString().slice(0, 10)}.json`;
- link.click();
- URL.revokeObjectURL(url);
- };
+ const [exportDialog, setExportDialog] = useState<"json" | "email" | null>(null);
+ const [exportMessage, setExportMessage] = useState("");
const clear = () => {
deleteAllReports(demoMode);
@@ -1919,17 +1932,21 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
+ );
+}
+
function EvaluatorApp() {
const { labels, t } = useI18n();
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
@@ -2880,6 +3179,17 @@ function periodBounds(period: TrendPeriod): [number | null, number | null] {
return [start, end];
}
+export function graphDomainStart(times: number[], selectedStart: number | null = null) {
+ const fallback = times.length ? Math.min(...times) : Date.now();
+ return startOfDayTime(selectedStart ?? fallback);
+}
+
+function startOfDayTime(time: number) {
+ const date = new Date(time);
+ date.setHours(0, 0, 0, 0);
+ return date.getTime();
+}
+
function toDateInput(date: Date) {
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
}
@@ -2899,6 +3209,9 @@ function createTimeMarkers(minTime: number, maxTime: number) {
for (let time = start; time <= maxTime; time += selected.step) {
if (time >= minTime) markers.push({ time, label: formatMarker(time, selected.unit) });
}
+ if (!markers.some((marker) => marker.time === minTime)) {
+ markers.unshift({ time: minTime, label: formatMarker(minTime, "day") });
+ }
return markers.slice(0, 10);
}
diff --git a/src/graph.test.ts b/src/graph.test.ts
index a4d9f89..650faf3 100644
--- a/src/graph.test.ts
+++ b/src/graph.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { buildFollowupResponsePoints, buildWorkTimeBands, calculateFollowupEffectScore, createLineSegments, formatDateTime } from "./App";
+import { buildFollowupResponsePoints, buildWorkTimeBands, calculateFollowupEffectScore, createLineSegments, formatDateTime, graphDomainStart } from "./App";
import { datePlaceholderForLanguage } from "./i18n";
import type { DelayedFollowup, Report } from "./types";
@@ -109,4 +109,16 @@ describe("oppfølgingsrespons", () => {
expect(datePlaceholderForLanguage("nb-NO")).toBe("dd/mm/åååå");
expect(datePlaceholderForLanguage("en")).toBe("dd/mm/yyyy");
});
+
+ it("runder grafstart ned til 00:00 på første synlige dato", () => {
+ const pointTime = new Date("2026-05-27T11:00:00").getTime();
+ const domainStart = graphDomainStart([pointTime]);
+ const startDate = new Date(domainStart);
+
+ expect(startDate.getHours()).toBe(0);
+ expect(startDate.getMinutes()).toBe(0);
+ expect(startDate.getFullYear()).toBe(2026);
+ expect(startDate.getMonth()).toBe(4);
+ expect(startDate.getDate()).toBe(27);
+ });
});
diff --git a/src/i18n.tsx b/src/i18n.tsx
index 5528a59..3395171 100644
--- a/src/i18n.tsx
+++ b/src/i18n.tsx
@@ -157,9 +157,39 @@ const nb = {
fiveYears: "5 år"
},
privacy: {
- warning: "Rapportene kan inneholde sensitive helseopplysninger og lagres bare lokalt i denne nettleseren.",
+ warning: "Rapportene kan inneholde sensitive helseopplysninger. Arbeidspuls lagrer data lokalt som standard, med valgfri kryptert engangsdeling når du selv velger Send via e-post.",
demoWarning: "Demo-modus bruker egen lokal lagring. Disse rapportene er testdata og blandes ikke med faktiske rapporter.",
exportJson: "Eksporter JSON",
+ exportEmail: "Send via e-post",
+ exportRangeTitle: "Eksporter data",
+ exportRangeIntro: "Velg eventuelt en periode for eksporten. Hvis ingen datoer velges, eksporteres all lokal data.",
+ fromDate: "Fra dato",
+ toDate: "Til dato",
+ allDataDefault: "Standard: all data eksporteres.",
+ exportInvalidRange: "Fra-dato kan ikke være etter til-dato.",
+ exportSensitiveNotice: "Eksportfilen kan inneholde sensitive helse- og funksjonsopplysninger. Del den bare med personer som skal ha tilgang.",
+ emailExportTitle: "Send data via e-post",
+ recipientEmail: "Mottakers e-post",
+ recipientPlaceholder: "veileder@example.com",
+ openEmail: "Åpne e-post",
+ invalidEmail: "Skriv inn en gyldig e-postadresse.",
+ emailSubject: "Arbeidspuls-rapportdata for gjennomgang",
+ emailShared: "Sikker deling er opprettet.",
+ manualAttachmentRequired: "Kopier teksten manuelt hvis e-postklienten ikke åpnet riktig.",
+ limitPeriod: "Begrens periode",
+ allDataSelected: "All data er valgt som standard.",
+ emailClientNotice: "For automatisk åpning må en e-postklient være satt opp på enheten din. Hvis dette ikke fungerer, får du en tekst du kan kopiere og lime inn manuelt.",
+ serverStorageConsent: "Jeg forstår at valgt data midlertidig lagres kryptert og uleselig på Arbeidspuls-serveren for å opprette en engangslenke. Data slettes etter første vellykkede import eller etter 7 dager.",
+ serverStorageConsentRequired: "Du må godkjenne midlertidig kryptert serverlagring før lenken opprettes.",
+ createEmail: "Opprett e-post",
+ emailLinkCreated: "E-postklient er forsøkt åpnet. Bruk copy-paste-teksten hvis den ikke ble fylt ut riktig.",
+ emailLinkFailed: "Kunne ikke opprette sikker deling.",
+ copyPasteBackupTitle: "Copy-paste backup",
+ copyPasteBackupIntro: "Hvis e-postklienten ikke åpnet eller fylte inn teksten riktig, kopier mottaker, emne og tekst manuelt.",
+ emailSubjectLabel: "Emne",
+ emailBodyLabel: "E-posttekst",
+ secureEmailBody:
+ "Hei\n\nJeg deler Arbeidspuls-data med deg for gjennomgang.\n\nÅpne denne engangslenken for å hente og importere dataene i veiledervisning:\n{shareUrl}\n\nLenken kan brukes én gang og utløper automatisk etter 7 dager.\n\nSlik gjør du:\n1. Åpne lenken.\n2. Trykk på \"Hent og importer\".\n3. Se graf, rapporter og oppfølginger i veiledervisning.\n4. Du kan legge til egne notater ved behov.\n\nDataene lagres lokalt i din nettleser/enhet etter import. Arbeidspuls-serveren lagrer kun en midlertidig kryptert og uleselig kopi frem til dataene er hentet, eller i maksimalt 7 dager.\n\nArbeidspuls\n{appUrl}\n",
importBackup: "Importer sikkerhetskopi",
deleteAll: "Slett alle lokale data",
importHelp: "Importer tidligere eksportert JSON hvis du har byttet enhet eller mistet lokale data. Importerte data lagres lokalt i denne nettleseren.",
@@ -170,22 +200,37 @@ const nb = {
cancelImport: "Avbryt import",
importData: "Importer data",
baseCopy:
- "Appen samler ikke inn navn, fødselsnummer eller kontaktinformasjon. Data sendes ikke til eksterne tjenester av appen. Fritekst er valgfritt og bør bare brukes når det er nødvendig.",
+ "Appen samler ikke inn navn, fødselsnummer eller kontaktinformasjon. Data lagres lokalt som standard. Ved Send via e-post krypteres valgt eksport i nettleseren før midlertidig opplasting. Fritekst er valgfritt og bør bare brukes når det er nødvendig.",
storageTitle: "Hvor lagres dataene?",
storageBody:
- "Rapportene lagres lokalt i nettleseren på enheten du bruker, i localStorage. Det betyr at dataene normalt blir liggende på telefonen eller datamaskinen din.",
+ "Rapportene lagres lokalt i nettleseren på enheten du bruker, i localStorage. Hvis du velger Send via e-post, lastes bare kryptert og uleselig delingsdata midlertidig opp til Arbeidspuls-serveren.",
backupTitle: "Backup og enhetsbytte",
backupBody:
"Fordi dataene lagres lokalt, kan de gå tapt hvis du bytter enhet, sletter nettleserdata, bruker en annen nettleser eller tilbakestiller enheten. Eksporter JSON-fil jevnlig hvis du vil ta vare på rapportene.",
sharingTitle: "Deling med veileder",
sharingBody:
- "Hvis en veileder eller annen person skal gjennomgå rapportene, må du eksportere en JSON-fil og dele filen med dem, for eksempel via e-post. Mottakeren kan importere JSON-filen i veiledervisningen.",
+ "Hvis en veileder eller annen person skal gjennomgå rapportene, kan du eksportere en lokal JSON-fil eller bruke Send via e-post. E-postdeling lager en kryptert engangslenke der nøkkelen ligger i lenkefragmentet og aldri sendes til serveren.",
sensitiveTitle: "Sensitive opplysninger",
sensitiveBody:
- "Rapportene kan inneholde helse- og funksjonsrelatert informasjon. Del JSON-filen på en måte som passer for sensitiv informasjon, og bare med personer som faktisk skal ha tilgang.",
+ "Rapportene kan inneholde helse- og funksjonsrelatert informasjon. Del JSON-filer eller engangslenker bare med personer som faktisk skal ha tilgang. Krypterte delinger slettes etter første vellykkede import eller automatisk etter 7 dager.",
exportIntegrity: "Eksport inkluderer lokal integritetskontroll der rapportene har verifiseringsdata.",
deleteAllName: "alle lokale rapporter"
},
+ share: {
+ title: "Arbeidspuls-deling",
+ intro: "Du har åpnet en engangsdeling av Arbeidspuls-data.",
+ activeImportNotice: "Delingen hentes ikke før du trykker på knappen. Data dekrypteres og importeres lokalt i veiledervisningen.",
+ fetchAndImport: "Hent og importer",
+ fetching: "Henter...",
+ openAdvisor: "Åpne veiledervisning",
+ alreadyDeleted: "Denne delingen er allerede hentet eller slettet.",
+ expired: "Denne delingen er automatisk slettet fordi den er mer enn 7 dager gammel.",
+ requestNewShare: "Be avsenderen opprette en ny deling hvis du fortsatt trenger tilgang.",
+ missingKey: "Lenken mangler dekrypteringsnøkkel. Be avsenderen sende hele lenken på nytt.",
+ decryptFailed: "Kunne ikke dekryptere delingen. Lenken kan være ufullstendig eller feil.",
+ notFound: "Delingen finnes ikke.",
+ imported: "Import fullført. Nye oppføringer: {reports}. Duplikater hoppet over: {skipped}."
+ },
evaluator: {
eyebrow: "Veileder",
title: "Veiledergjennomgang",
@@ -459,9 +504,39 @@ const en: TranslationTree = {
fiveYears: "5 years"
},
privacy: {
- warning: "Reports may contain sensitive health information and are stored only locally in this browser.",
+ warning: "Reports may contain sensitive health information. Arbeidspuls stores data locally by default, with optional encrypted one-time sharing when you choose Send by email.",
demoWarning: "Demo mode uses separate local storage. These reports are test data and are not mixed with real reports.",
exportJson: "Export JSON",
+ exportEmail: "Send by email",
+ exportRangeTitle: "Export data",
+ exportRangeIntro: "Optionally choose a date range for the export. If no dates are selected, all local data is exported.",
+ fromDate: "From date",
+ toDate: "To date",
+ allDataDefault: "Default: all data is exported.",
+ exportInvalidRange: "From date cannot be after to date.",
+ exportSensitiveNotice: "The export file may contain sensitive health and function-related information. Only share it with people who should have access.",
+ emailExportTitle: "Send data by email",
+ recipientEmail: "Recipient email",
+ recipientPlaceholder: "advisor@example.com",
+ openEmail: "Open email",
+ invalidEmail: "Enter a valid email address.",
+ emailSubject: "Arbeidspuls report data for review",
+ emailShared: "Secure share has been created.",
+ manualAttachmentRequired: "Copy the text manually if the email client did not open correctly.",
+ limitPeriod: "Limit date range",
+ allDataSelected: "All data is selected by default.",
+ emailClientNotice: "Automatic opening requires an email client to be configured on your device. If this does not work, you will get text you can copy and paste manually.",
+ serverStorageConsent: "I understand that the selected data is temporarily stored encrypted and unreadable on the Arbeidspuls server to create a one-time link. The data is deleted after the first successful import or after 7 days.",
+ serverStorageConsentRequired: "You must approve temporary encrypted server storage before the link is created.",
+ createEmail: "Create email",
+ emailLinkCreated: "The email client was opened if available. Use the copy-paste text if it was not filled in correctly.",
+ emailLinkFailed: "Could not create secure share.",
+ copyPasteBackupTitle: "Copy-paste backup",
+ copyPasteBackupIntro: "If the email client did not open or fill in the text correctly, copy the recipient, subject and text manually.",
+ emailSubjectLabel: "Subject",
+ emailBodyLabel: "Email text",
+ secureEmailBody:
+ "Hello\n\nI am sharing Arbeidspuls data with you for review.\n\nOpen this one-time link to fetch and import the data in advisor view:\n{shareUrl}\n\nThe link can be used once and expires automatically after 7 days.\n\nHow to proceed:\n1. Open the link.\n2. Press \"Fetch and import\".\n3. Review graphs, reports and follow-ups in advisor view.\n4. You can add your own notes if needed.\n\nThe data is stored locally in your browser/device after import. The Arbeidspuls server only stores a temporary encrypted and unreadable copy until the data is fetched, or for a maximum of 7 days.\n\nArbeidspuls\n{appUrl}\n",
importBackup: "Import backup",
deleteAll: "Delete all local data",
importHelp: "Import previously exported JSON if you changed device or lost local data. Imported data is stored locally in this browser.",
@@ -471,22 +546,37 @@ const en: TranslationTree = {
cancelImport: "Cancel import",
importData: "Import data",
baseCopy:
- "The app does not collect names, national identity numbers or contact information. Data is not sent to external services by the app. Free text is optional and should only be used when needed.",
+ "The app does not collect names, national identity numbers or contact information. Data is stored locally by default. With Send by email, the selected export is encrypted in the browser before temporary upload. Free text is optional and should only be used when needed.",
storageTitle: "Where is the data stored?",
storageBody:
- "Reports are stored locally in the browser on the device you use, in localStorage. This means the data normally remains on your phone or computer.",
+ "Reports are stored locally in the browser on the device you use, in localStorage. If you choose Send by email, only encrypted and unreadable share data is temporarily uploaded to the Arbeidspuls server.",
backupTitle: "Backup and device changes",
backupBody:
"Because the data is stored locally, it may be lost if you change device, delete browser data, use another browser or reset the device. Export a JSON file regularly if you want to keep the reports.",
sharingTitle: "Sharing with an advisor",
sharingBody:
- "If an advisor or another person will review the reports, export a JSON file and share it with them, for example by email. The recipient can import the JSON file in the advisor view.",
+ "If an advisor or another person will review the reports, you can export a local JSON file or use Send by email. Email sharing creates an encrypted one-time link where the key is in the URL fragment and is never sent to the server.",
sensitiveTitle: "Sensitive information",
sensitiveBody:
- "Reports may contain health and function-related information. Share the JSON file in a way suitable for sensitive information, and only with people who should have access.",
+ "Reports may contain health and function-related information. Only share JSON files or one-time links with people who should have access. Encrypted shares are deleted after the first successful import or automatically after 7 days.",
exportIntegrity: "Export includes local integrity checks where reports have verification data.",
deleteAllName: "all local reports"
},
+ share: {
+ title: "Arbeidspuls share",
+ intro: "You have opened a one-time share of Arbeidspuls data.",
+ activeImportNotice: "The share is not fetched until you press the button. Data is decrypted and imported locally in the advisor view.",
+ fetchAndImport: "Fetch and import",
+ fetching: "Fetching...",
+ openAdvisor: "Open advisor view",
+ alreadyDeleted: "This share has already been fetched or deleted.",
+ expired: "This share was automatically deleted because it is more than 7 days old.",
+ requestNewShare: "Ask the sender to create a new share if you still need access.",
+ missingKey: "The link is missing its decryption key. Ask the sender to send the full link again.",
+ decryptFailed: "Could not decrypt the share. The link may be incomplete or incorrect.",
+ notFound: "The share does not exist.",
+ imported: "Import complete. New entries: {reports}. Duplicates skipped: {skipped}."
+ },
evaluator: {
eyebrow: "Advisor",
title: "Advisor review",
diff --git a/src/secureShare.test.ts b/src/secureShare.test.ts
new file mode 100644
index 0000000..3fdc57e
--- /dev/null
+++ b/src/secureShare.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { base64UrlDecode, base64UrlEncode, parseShareRequestFromLocation } from "./secureShare";
+
+describe("sikker deling", () => {
+ it("leser export id fra /s?uuid og nøkkel fra fragment", () => {
+ const parsed = parseShareRequestFromLocation({
+ search: "?123e4567-e89b-12d3-a456-426614174000",
+ hash: "#key=abc_def-123"
+ } as Location);
+
+ expect(parsed.exportId).toBe("123e4567-e89b-12d3-a456-426614174000");
+ expect(parsed.key).toBe("abc_def-123");
+ });
+
+ it("base64url roundtripper bytes uten pluss, slash eller padding", () => {
+ const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
+ const encoded = base64UrlEncode(bytes);
+
+ expect(encoded).not.toMatch(/[+/=]/);
+ expect([...base64UrlDecode(encoded)]).toEqual([...bytes]);
+ });
+});
diff --git a/src/secureShare.ts b/src/secureShare.ts
new file mode 100644
index 0000000..4a64005
--- /dev/null
+++ b/src/secureShare.ts
@@ -0,0 +1,114 @@
+import type { ExportPayload } from "./types";
+
+const SHARE_SCHEMA = "arbeidspuls-secure-share-v1";
+
+export type SecureShareCreateResponse = {
+ export_id: string;
+ expires_at: string;
+};
+
+export type SecureShareFetchResponse =
+ | {
+ status: "available";
+ export_id: string;
+ ciphertext: string;
+ iv: string;
+ schema: string;
+ expires_at: string;
+ }
+ | {
+ status: "deleted" | "expired" | "not_found";
+ export_id?: string;
+ };
+
+export type SecureShareLink = {
+ exportId: string;
+ expiresAt: string;
+ shareUrl: string;
+};
+
+export type ShareRequest = {
+ exportId: string | null;
+ key: string | null;
+};
+
+export function parseShareRequestFromLocation(location: Pick): ShareRequest {
+ const search = location.search.startsWith("?") ? location.search.slice(1) : location.search;
+ const params = new URLSearchParams(search);
+ const positionalId = search && !search.includes("=") ? decodeURIComponent(search) : null;
+ const exportId = params.get("id") ?? params.get("export_id") ?? positionalId;
+ const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
+ const key = new URLSearchParams(hash).get("key");
+ return { exportId, key };
+}
+
+export async function createSecureShare(jsonText: string, origin = window.location.origin): Promise {
+ const encrypted = await encryptJsonForShare(jsonText);
+ const response = await fetch("/api/share", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ciphertext: encrypted.ciphertext,
+ iv: encrypted.iv,
+ schema: SHARE_SCHEMA
+ })
+ });
+ if (!response.ok) throw new Error("Kunne ikke opprette sikker deling.");
+ const result = await response.json() as SecureShareCreateResponse;
+ return {
+ exportId: result.export_id,
+ expiresAt: result.expires_at,
+ shareUrl: `${origin}/s?${encodeURIComponent(result.export_id)}#key=${encrypted.key}`
+ };
+}
+
+export async function fetchSecureShare(exportId: string): Promise {
+ const response = await fetch(`/api/share/${encodeURIComponent(exportId)}`);
+ if (response.status === 404) return { status: "not_found", export_id: exportId };
+ if (!response.ok) throw new Error("Kunne ikke hente delingen.");
+ return await response.json() as SecureShareFetchResponse;
+}
+
+export async function confirmSecureShareImport(exportId: string): Promise {
+ const response = await fetch(`/api/share/${encodeURIComponent(exportId)}/confirm-import`, { method: "POST" });
+ if (!response.ok) throw new Error("Importen ble lagret lokalt, men delingen kunne ikke bekreftes slettet.");
+}
+
+export async function decryptSecureSharePayload(payload: { ciphertext: string; iv: string }, keyValue: string): Promise {
+ const key = await crypto.subtle.importKey("raw", base64UrlDecode(keyValue), { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
+ const decrypted = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: base64UrlDecode(payload.iv) },
+ key,
+ base64UrlDecode(payload.ciphertext)
+ );
+ return JSON.parse(new TextDecoder().decode(decrypted)) as ExportPayload;
+}
+
+async function encryptJsonForShare(jsonText: string) {
+ const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
+ const rawKey = await crypto.subtle.exportKey("raw", key);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const data = new TextEncoder().encode(jsonText);
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
+ return {
+ ciphertext: base64UrlEncode(new Uint8Array(ciphertext)),
+ iv: base64UrlEncode(iv),
+ key: base64UrlEncode(new Uint8Array(rawKey))
+ };
+}
+
+export function base64UrlEncode(bytes: Uint8Array) {
+ let binary = "";
+ for (let index = 0; index < bytes.length; index += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+}
+
+export function base64UrlDecode(value: string) {
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return bytes;
+}
diff --git a/src/storage.test.ts b/src/storage.test.ts
index 9dee038..5c5cd1b 100644
--- a/src/storage.test.ts
+++ b/src/storage.test.ts
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createFollowup,
exportReports,
+ filterEntriesForExport,
importReportsFromJson,
listReports,
previewImportReports,
@@ -9,7 +10,7 @@ import {
updateFollowup,
updateReport
} from "./storage";
-import type { DelayedFollowup, ExportPayload, Report } from "./types";
+import type { DelayedFollowup, ExportPayload, Report, StoredEntry } from "./types";
const store = new Map();
@@ -42,7 +43,7 @@ const report: Report = {
helpful_accommodations: []
};
-function payload(reports: Report[]): ExportPayload {
+function payload(reports: StoredEntry[]): ExportPayload {
return {
schema_version: 2,
export_version: 2,
@@ -198,4 +199,37 @@ describe("ordinær brukerimport", () => {
expect(updated.worse_than_before).toBe("mye");
expect(updated.integrity?.verification_status).toBe("valid");
});
+
+ it("filtrerer eksport på fra-dato, til-dato og intervall inklusivt", () => {
+ const first = { ...report, id: "rapport-1", created_at: "2026-05-27T06:00:00.000Z", report_date: "2026-05-27" };
+ const second = { ...report, id: "rapport-2", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
+ const followup: DelayedFollowup = {
+ id: "oppfolging-1",
+ created_at: "2026-05-29T08:00:00.000Z",
+ report_date: "2026-05-29",
+ report_time: "10:00:00",
+ type: "delayed_followup",
+ parent_report_id: second.id,
+ worse_than_before: "litt",
+ delayed_symptoms: []
+ };
+ const entries = [first, second, followup];
+
+ expect(filterEntriesForExport(entries, {})).toHaveLength(3);
+ expect(filterEntriesForExport(entries, { fromDate: "2026-05-28" }).map((entry) => entry.id)).toEqual(["oppfolging-1", "rapport-2"]);
+ expect(filterEntriesForExport(entries, { toDate: "2026-05-28" }).map((entry) => entry.id)).toEqual(["rapport-2", "rapport-1"]);
+ expect(filterEntriesForExport(entries, { fromDate: "2026-05-28", toDate: "2026-05-28" }).map((entry) => entry.id)).toEqual(["rapport-2"]);
+ });
+
+ it("eksporterer filtrert JSON med oppdatert manifest og blokkerer ugyldig intervall", () => {
+ const first = { ...report, id: "rapport-1", created_at: "2026-05-27T06:00:00.000Z", report_date: "2026-05-27" };
+ const second = { ...report, id: "rapport-2", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
+ importReportsFromJson(JSON.stringify(payload([first, second])));
+
+ const exported = JSON.parse(exportReports(false, { fromDate: "2026-05-28", toDate: "2026-05-28" })) as ExportPayload;
+
+ expect(exported.reports.map((entry) => entry.id)).toEqual(["rapport-2"]);
+ expect(exported.export_integrity?.manifest_hash_sha256).toBeTruthy();
+ expect(() => exportReports(false, { fromDate: "2026-05-29", toDate: "2026-05-28" })).toThrow("Fra-dato");
+ });
});
diff --git a/src/storage.ts b/src/storage.ts
index 0deb0cd..56fc423 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -9,6 +9,10 @@ const DEMO_MODE_KEY = "arbeidspuls:demo-mode";
const DEMO_SEEDED_KEY = "arbeidspuls:demo-seeded";
export type DataMode = "normal" | "demo";
+export type ExportRange = {
+ fromDate?: string;
+ toDate?: string;
+};
function storageKey(mode: DataMode) {
return mode === "demo" ? DEMO_STORAGE_KEY : STORAGE_KEY;
@@ -251,8 +255,8 @@ export function deleteAllReports(demoMode = false) {
writeEntries([], modeFromDemoFlag(demoMode));
}
-export function exportReports(demoMode = false) {
- const reports = listReports(demoMode);
+export function exportReports(demoMode = false, range: ExportRange = {}) {
+ const reports = filterEntriesForExport(listReports(demoMode), range);
const payload: ExportPayload = {
schema_version: 3,
export_version: 3,
@@ -265,6 +269,44 @@ export function exportReports(demoMode = false) {
return JSON.stringify(payload, null, 2);
}
+export function filterEntriesForExport(entries: StoredEntry[], range: ExportRange = {}) {
+ assertValidExportRange(range);
+ const start = range.fromDate ? new Date(`${range.fromDate}T00:00:00`).getTime() : null;
+ const end = range.toDate ? new Date(`${range.toDate}T23:59:59.999`).getTime() : null;
+ return entries
+ .filter((entry) => {
+ const time = getExportEntryTime(entry);
+ if (start !== null && time < start) return false;
+ if (end !== null && time > end) return false;
+ return true;
+ })
+ .sort((a, b) => getExportEntryTime(b) - getExportEntryTime(a));
+}
+
+export function assertValidExportRange(range: ExportRange) {
+ if (!range.fromDate || !range.toDate) return;
+ const start = new Date(`${range.fromDate}T00:00:00`).getTime();
+ const end = new Date(`${range.toDate}T23:59:59.999`).getTime();
+ if (Number.isFinite(start) && Number.isFinite(end) && start > end) {
+ throw new Error("Fra-dato kan ikke være etter til-dato.");
+ }
+}
+
+export function buildExportFilename(demoMode = false, range: ExportRange = {}) {
+ const prefix = demoMode ? "arbeidspuls-demo" : "arbeidspuls";
+ const today = new Date().toISOString().slice(0, 10);
+ if (range.fromDate && range.toDate) return `${prefix}-rapporter-${range.fromDate}_til_${range.toDate}.json`;
+ if (range.fromDate) return `${prefix}-rapporter-fra_${range.fromDate}.json`;
+ if (range.toDate) return `${prefix}-rapporter-til_${range.toDate}.json`;
+ return `${prefix}-rapporter-${today}.json`;
+}
+
+function getExportEntryTime(entry: StoredEntry) {
+ const displayed = Date.parse(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
+ if (!Number.isNaN(displayed)) return displayed;
+ return getEntryTime(entry);
+}
+
export function previewImportReports(jsonText: string, demoMode = false) {
const parsed = JSON.parse(jsonText);
const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
diff --git a/src/styles.css b/src/styles.css
index 5eb5c7c..70827b2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -370,6 +370,7 @@ textarea {
color: #9e2736;
}
+.primary-action:disabled,
.secondary-action:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -1002,6 +1003,7 @@ textarea {
}
.report-modal,
+.export-modal,
.confirm-modal {
position: relative;
width: min(720px, 100%);
@@ -1014,6 +1016,85 @@ textarea {
padding: 16px;
}
+.export-modal {
+ display: grid;
+ gap: 12px;
+ width: min(560px, 100%);
+}
+
+.export-date-range {
+ margin-top: 0;
+}
+
+.privacy-management-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.privacy-management-row .secondary-action {
+ min-height: 44px;
+}
+
+.compact-toggle {
+ width: fit-content;
+}
+
+.consent-check {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ border: 1px solid #d9ded8;
+ border-radius: 8px;
+ background: #f7faf8;
+ color: #35413f;
+ font-size: 0.86rem;
+ font-weight: 750;
+ line-height: 1.4;
+ padding: 10px;
+}
+
+.consent-check input {
+ margin-top: 3px;
+}
+
+.copy-backup {
+ display: grid;
+ gap: 10px;
+ border: 1px solid #d9ded8;
+ border-radius: 8px;
+ background: #f7faf8;
+ padding: 12px;
+}
+
+.copy-backup h3,
+.copy-backup p {
+ margin: 0;
+}
+
+.copy-backup textarea {
+ min-height: 170px;
+ resize: vertical;
+}
+
+.share-card {
+ display: grid;
+ gap: 14px;
+ width: min(760px, 100%);
+ border: 1px solid #d9ded8;
+ border-radius: 8px;
+ background: #fffefa;
+ box-shadow: 0 10px 28px rgba(46, 51, 48, 0.06);
+ margin: 0 auto;
+ padding: 16px;
+}
+
+.share-card p {
+ margin: 0;
+ color: #35413f;
+ line-height: 1.45;
+}
+
.graph-modal-overlay {
align-items: stretch;
padding: 14px;
@@ -1884,6 +1965,10 @@ textarea {
}
@media (max-width: 640px) {
+ .privacy-management-row {
+ grid-template-columns: 1fr;
+ }
+
.review-card-main {
grid-template-columns: 1fr;
}
diff --git a/vite.config.ts b/vite.config.ts
index db1b19e..a25b10c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,5 +2,10 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
- plugins: [react()]
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api/share": "http://127.0.0.1:8000"
+ }
+ }
});