Release v1.1.1 demo advisor mode
This commit is contained in:
parent
82da79cc4a
commit
76ec48bfef
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "arbeidspuls-v5";
|
const CACHE_NAME = "arbeidspuls-v6";
|
||||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
118
src/App.tsx
118
src/App.tsx
@ -37,6 +37,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteEvaluatorNote,
|
deleteEvaluatorNote,
|
||||||
deleteEvaluatorProfile,
|
deleteEvaluatorProfile,
|
||||||
|
ensureDemoEvaluatorProfiles,
|
||||||
exportEvaluatorBackup,
|
exportEvaluatorBackup,
|
||||||
importEvaluatorBackup,
|
importEvaluatorBackup,
|
||||||
importExportPayload,
|
importExportPayload,
|
||||||
@ -153,20 +154,14 @@ function AppRoutes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
|
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
|
||||||
{demoMode && <DemoBanner onExit={() => changeDemoMode(false)} />}
|
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{t("app.eyebrow")}</p>
|
<p className="eyebrow">{t("app.eyebrow")}</p>
|
||||||
<h1>{t("app.title")}</h1>
|
<h1>{t("app.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<a className="text-link" href="/veileder">
|
||||||
<button className={demoMode ? "text-link active" : "text-link"} onClick={() => changeDemoMode(!demoMode)} type="button">
|
{t("app.evaluator")}
|
||||||
{demoMode ? t("demo.exit") : t("demo.enter")}
|
</a>
|
||||||
</button>
|
|
||||||
<a className="text-link" href="/veileder">
|
|
||||||
{t("app.evaluator")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
||||||
@ -206,7 +201,7 @@ function AppRoutes() {
|
|||||||
)}
|
)}
|
||||||
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
|
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
itemName={deleteRequest?.name ?? ""}
|
itemName={deleteRequest?.name ?? ""}
|
||||||
open={Boolean(deleteRequest)}
|
open={Boolean(deleteRequest)}
|
||||||
@ -242,21 +237,7 @@ function NavButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DemoBanner({ onExit }: { onExit: () => void }) {
|
function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoModeChange: (enabled: boolean) => void }) {
|
||||||
const { t } = useI18n();
|
|
||||||
return (
|
|
||||||
<div className="demo-banner" role="status">
|
|
||||||
<span>DEMO</span>
|
|
||||||
<span aria-hidden="true">[</span>
|
|
||||||
<button aria-label={t("demo.exit")} onClick={onExit} type="button">
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
<span aria-hidden="true">]</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppFooter() {
|
|
||||||
const { language, setLanguage, t } = useI18n();
|
const { language, setLanguage, t } = useI18n();
|
||||||
const baseYear = 2026;
|
const baseYear = 2026;
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@ -292,6 +273,9 @@ function AppFooter() {
|
|||||||
<Github aria-hidden="true" />
|
<Github aria-hidden="true" />
|
||||||
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
|
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<button className={demoMode ? "footer-link active" : "footer-link"} onClick={() => onDemoModeChange(!demoMode)} type="button">
|
||||||
|
{demoMode ? t("demo.exit") : t("demo.enter")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
@ -1687,8 +1671,9 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
|
|||||||
|
|
||||||
function EvaluatorApp() {
|
function EvaluatorApp() {
|
||||||
const { labels, t } = useI18n();
|
const { labels, t } = useI18n();
|
||||||
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles());
|
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles()[0]?.id ?? null);
|
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles(isDemoModeEnabled()));
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles(isDemoModeEnabled())[0]?.id ?? null);
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
@ -1705,10 +1690,27 @@ function EvaluatorApp() {
|
|||||||
if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
|
if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
|
||||||
}, [selected?.id]);
|
}, [selected?.id]);
|
||||||
|
|
||||||
const refresh = (nextSelectedId?: string) => {
|
useEffect(() => {
|
||||||
const next = listEvaluatorProfiles();
|
const next = listEvaluatorProfiles(demoMode);
|
||||||
setProfiles(next);
|
setProfiles(next);
|
||||||
if (nextSelectedId) setSelectedId(nextSelectedId);
|
setSelectedId((current) => (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
|
||||||
|
setSelectedEntryId(null);
|
||||||
|
}, [demoMode]);
|
||||||
|
|
||||||
|
const refresh = (nextSelectedId?: string) => {
|
||||||
|
const next = listEvaluatorProfiles(demoMode);
|
||||||
|
setProfiles(next);
|
||||||
|
setSelectedId((current) => nextSelectedId ?? (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeDemoMode = (enabled: boolean) => {
|
||||||
|
setDemoModeEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
ensureDemoData();
|
||||||
|
ensureDemoEvaluatorProfiles();
|
||||||
|
}
|
||||||
|
setDemoMode(enabled);
|
||||||
|
setMessage(enabled ? t("demo.notice") : "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -1716,7 +1718,7 @@ function EvaluatorApp() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = importExportPayload(await file.text(), displayName, targetId || undefined);
|
const result = importExportPayload(await file.text(), displayName, targetId || undefined, demoMode);
|
||||||
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
|
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
|
||||||
const actionText =
|
const actionText =
|
||||||
result.target_resolution === "matched_existing_person"
|
result.target_resolution === "matched_existing_person"
|
||||||
@ -1739,8 +1741,10 @@ function EvaluatorApp() {
|
|||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
importEvaluatorBackup(await file.text());
|
const text = await file.text();
|
||||||
setMessage(t("evaluator.importedBackup"));
|
const demoEntries = countEvaluatorBackupDemoEntries(text);
|
||||||
|
importEvaluatorBackup(text, demoMode);
|
||||||
|
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
|
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
|
||||||
@ -1750,32 +1754,32 @@ function EvaluatorApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadBackup = () => {
|
const downloadBackup = () => {
|
||||||
const blob = new Blob([exportEvaluatorBackup()], { type: "application/json" });
|
const blob = new Blob([exportEvaluatorBackup(demoMode)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `arbeidsevne-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
|
link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveName = () => {
|
const saveName = () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
renameEvaluatorProfile(selected.id, selected.display_name);
|
renameEvaluatorProfile(selected.id, selected.display_name, demoMode);
|
||||||
refresh(selected.id);
|
refresh(selected.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePersonNote = () => {
|
const savePersonNote = () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" });
|
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode);
|
||||||
setPersonNote("");
|
setPersonNote("");
|
||||||
refresh(selected.id);
|
refresh(selected.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelectedPerson = () => {
|
const deleteSelectedPerson = () => {
|
||||||
if (!deletePersonRequest) return;
|
if (!deletePersonRequest) return;
|
||||||
deleteEvaluatorProfile(deletePersonRequest.id);
|
deleteEvaluatorProfile(deletePersonRequest.id, demoMode);
|
||||||
const remaining = listEvaluatorProfiles();
|
const remaining = listEvaluatorProfiles(demoMode);
|
||||||
setProfiles(remaining);
|
setProfiles(remaining);
|
||||||
setSelectedId(remaining[0]?.id ?? null);
|
setSelectedId(remaining[0]?.id ?? null);
|
||||||
setSelectedEntryId(null);
|
setSelectedEntryId(null);
|
||||||
@ -1801,7 +1805,7 @@ function EvaluatorApp() {
|
|||||||
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
|
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell evaluator-shell">
|
<div className={demoMode ? "app-shell evaluator-shell demo-active" : "app-shell evaluator-shell"}>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
|
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
|
||||||
@ -1812,6 +1816,13 @@ function EvaluatorApp() {
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{demoMode && (
|
||||||
|
<div className="notice warning">
|
||||||
|
<ShieldAlert aria-hidden="true" />
|
||||||
|
<span>{t("demo.notice")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="evaluator-layout">
|
<main className="evaluator-layout">
|
||||||
<section className="evaluator-sidebar">
|
<section className="evaluator-sidebar">
|
||||||
<div className="field-card">
|
<div className="field-card">
|
||||||
@ -1968,6 +1979,7 @@ function EvaluatorApp() {
|
|||||||
followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []}
|
followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []}
|
||||||
notes={selected.evaluator_notes}
|
notes={selected.evaluator_notes}
|
||||||
profileId={selected.id}
|
profileId={selected.id}
|
||||||
|
demoMode={demoMode}
|
||||||
onSaved={() => refresh(selected.id)}
|
onSaved={() => refresh(selected.id)}
|
||||||
onClose={() => setSelectedEntryId(null)}
|
onClose={() => setSelectedEntryId(null)}
|
||||||
/>
|
/>
|
||||||
@ -1982,7 +1994,7 @@ function EvaluatorApp() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
open={Boolean(deletePersonRequest)}
|
open={Boolean(deletePersonRequest)}
|
||||||
itemName={
|
itemName={
|
||||||
@ -1997,6 +2009,20 @@ function EvaluatorApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countEvaluatorBackupDemoEntries(jsonText: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText) as { data_mode?: string; profiles?: EvaluatorProfile[] };
|
||||||
|
if (!Array.isArray(parsed.profiles)) return 0;
|
||||||
|
const count = parsed.profiles.reduce(
|
||||||
|
(total, profile) => total + (profile.contains_demo_data ? 1 : 0) + profile.reports.filter((entry) => entry.demo || entry.id.startsWith("demo-arbeidspuls")).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return parsed.data_mode === "demo" && count === 0 ? 1 : count;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewReportCard({
|
function ReviewReportCard({
|
||||||
report,
|
report,
|
||||||
followups,
|
followups,
|
||||||
@ -2130,6 +2156,7 @@ function ReviewDetailPane({
|
|||||||
followups,
|
followups,
|
||||||
notes,
|
notes,
|
||||||
profileId,
|
profileId,
|
||||||
|
demoMode,
|
||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
@ -2137,6 +2164,7 @@ function ReviewDetailPane({
|
|||||||
followups: DelayedFollowup[];
|
followups: DelayedFollowup[];
|
||||||
notes: EvaluatorProfile["evaluator_notes"];
|
notes: EvaluatorProfile["evaluator_notes"];
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
demoMode: boolean;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -2151,22 +2179,22 @@ function ReviewDetailPane({
|
|||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (entry.type === "work_report") {
|
if (entry.type === "work_report") {
|
||||||
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id });
|
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id }, demoMode);
|
||||||
} else {
|
} else {
|
||||||
upsertEvaluatorNote(profileId, noteText, {
|
upsertEvaluatorNote(profileId, noteText, {
|
||||||
note_type: "followup",
|
note_type: "followup",
|
||||||
related_followup_id: entry.id,
|
related_followup_id: entry.id,
|
||||||
parent_report_id: findParentId(entry)
|
parent_report_id: findParentId(entry)
|
||||||
});
|
}, demoMode);
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
if (entry.type === "work_report") {
|
if (entry.type === "work_report") {
|
||||||
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id });
|
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }, demoMode);
|
||||||
} else {
|
} else {
|
||||||
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id });
|
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }, demoMode);
|
||||||
}
|
}
|
||||||
setNoteText("");
|
setNoteText("");
|
||||||
onSaved();
|
onSaved();
|
||||||
|
|||||||
153
src/demoData.ts
153
src/demoData.ts
@ -1,4 +1,4 @@
|
|||||||
import type { StoredEntry } from "./types";
|
import type { EvaluatorProfile, StoredEntry } from "./types";
|
||||||
|
|
||||||
export const demoEntries: StoredEntry[] = [
|
export const demoEntries: StoredEntry[] = [
|
||||||
{
|
{
|
||||||
@ -182,3 +182,154 @@ export const demoEntries: StoredEntry[] = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const sharedDemoProfiles = {
|
||||||
|
created_at: "2026-05-28T08:00:00.000Z",
|
||||||
|
updated_at: "2026-05-28T08:00:00.000Z",
|
||||||
|
contains_demo_data: true,
|
||||||
|
evaluator_notes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const demoEvaluatorProfiles: EvaluatorProfile[] = [
|
||||||
|
{
|
||||||
|
...sharedDemoProfiles,
|
||||||
|
id: "demo-advisor-profile-01",
|
||||||
|
display_name: "Demo: Nora Hjemmekontor",
|
||||||
|
source_fingerprint: "demo-nora",
|
||||||
|
reports: demoEntries
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...sharedDemoProfiles,
|
||||||
|
id: "demo-advisor-profile-02",
|
||||||
|
display_name: "Demo: Amir Kontor",
|
||||||
|
source_fingerprint: "demo-amir",
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
id: "demo-arbeidspuls-amir-001",
|
||||||
|
created_at: "2026-05-18T07:20:00.000Z",
|
||||||
|
report_date: "2026-05-18",
|
||||||
|
report_time: "09:20:00",
|
||||||
|
type: "work_report",
|
||||||
|
workplace: "Kontor",
|
||||||
|
work_start_time: "08:30",
|
||||||
|
work_end_time: "11:30",
|
||||||
|
work_ability: 3,
|
||||||
|
energy_level: 3,
|
||||||
|
mental_clarity: 4,
|
||||||
|
symptom_burden: 2,
|
||||||
|
effort_strain: 3,
|
||||||
|
status: "kan_fortsette",
|
||||||
|
physical_energy: 3,
|
||||||
|
mental_energy: 4,
|
||||||
|
perceived_productivity: 3,
|
||||||
|
task_completion: "delvis",
|
||||||
|
task_types: ["sitting", "computer_work", "time_pressure"],
|
||||||
|
main_limitations: ["fatigue"],
|
||||||
|
helpful_accommodations: ["extra_breaks"],
|
||||||
|
note: "Testdata: moderat dag med noen pauser.",
|
||||||
|
total_score_percent: 68,
|
||||||
|
demo: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-arbeidspuls-amir-002",
|
||||||
|
created_at: "2026-05-23T10:45:00.000Z",
|
||||||
|
report_date: "2026-05-23",
|
||||||
|
report_time: "12:45:00",
|
||||||
|
type: "work_report",
|
||||||
|
workplace: "Kontor",
|
||||||
|
work_start_time: "10:00",
|
||||||
|
work_end_time: "12:30",
|
||||||
|
work_ability: 2,
|
||||||
|
energy_level: 2,
|
||||||
|
mental_clarity: 2,
|
||||||
|
symptom_burden: 4,
|
||||||
|
effort_strain: 5,
|
||||||
|
status: "trenger_pause",
|
||||||
|
physical_energy: 2,
|
||||||
|
mental_energy: 2,
|
||||||
|
perceived_productivity: 2,
|
||||||
|
task_completion: "litt",
|
||||||
|
task_types: ["sitting", "talking_social", "noise"],
|
||||||
|
main_limitations: ["fatigue", "sensory_overload", "brain_fog"],
|
||||||
|
helpful_accommodations: ["quiet_room", "shorter_task"],
|
||||||
|
note: "Testdata: støy og sosial belastning ga tydelig fall.",
|
||||||
|
total_score_percent: 24,
|
||||||
|
demo: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-arbeidspuls-amir-followup-002",
|
||||||
|
created_at: "2026-05-24T08:30:00.000Z",
|
||||||
|
report_date: "2026-05-24",
|
||||||
|
report_time: "10:30:00",
|
||||||
|
type: "delayed_followup",
|
||||||
|
parent_report_id: "demo-arbeidspuls-amir-002",
|
||||||
|
related_report_id: "demo-arbeidspuls-amir-002",
|
||||||
|
worse_than_before: "moderat",
|
||||||
|
delayed_symptoms: ["fatigue", "brain_fog", "headache"],
|
||||||
|
recovery_status: "to_tre_dager",
|
||||||
|
note: "Testdata: oppfølging etter krevende kontordag.",
|
||||||
|
demo: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...sharedDemoProfiles,
|
||||||
|
id: "demo-advisor-profile-03",
|
||||||
|
display_name: "Demo: Liv Butikk",
|
||||||
|
source_fingerprint: "demo-liv",
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
id: "demo-arbeidspuls-liv-001",
|
||||||
|
created_at: "2026-05-19T12:15:00.000Z",
|
||||||
|
report_date: "2026-05-19",
|
||||||
|
report_time: "14:15:00",
|
||||||
|
type: "work_report",
|
||||||
|
workplace: "Butikk",
|
||||||
|
work_start_time: "12:00",
|
||||||
|
work_end_time: "14:00",
|
||||||
|
work_ability: 3,
|
||||||
|
energy_level: 3,
|
||||||
|
mental_clarity: 3,
|
||||||
|
symptom_burden: 3,
|
||||||
|
effort_strain: 3,
|
||||||
|
status: "trenger_enklere_oppgave",
|
||||||
|
physical_energy: 3,
|
||||||
|
mental_energy: 3,
|
||||||
|
perceived_productivity: 3,
|
||||||
|
task_completion: "delvis",
|
||||||
|
task_types: ["standing", "walking", "talking_social"],
|
||||||
|
main_limitations: ["pain", "fatigue"],
|
||||||
|
helpful_accommodations: ["sitting_down", "help_from_others"],
|
||||||
|
note: "Testdata: fysisk aktivitet krevde tilrettelegging.",
|
||||||
|
total_score_percent: 50,
|
||||||
|
demo: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-arbeidspuls-liv-002",
|
||||||
|
created_at: "2026-05-26T07:50:00.000Z",
|
||||||
|
report_date: "2026-05-26",
|
||||||
|
report_time: "09:50:00",
|
||||||
|
type: "work_report",
|
||||||
|
workplace: "Butikk",
|
||||||
|
work_start_time: "09:00",
|
||||||
|
work_end_time: "09:45",
|
||||||
|
work_ability: 4,
|
||||||
|
energy_level: 4,
|
||||||
|
mental_clarity: 4,
|
||||||
|
symptom_burden: 2,
|
||||||
|
effort_strain: 2,
|
||||||
|
status: "kan_fortsette",
|
||||||
|
physical_energy: 4,
|
||||||
|
mental_energy: 4,
|
||||||
|
perceived_productivity: 4,
|
||||||
|
task_completion: "som_forventet",
|
||||||
|
task_types: ["standing", "walking"],
|
||||||
|
main_limitations: [],
|
||||||
|
helpful_accommodations: ["shorter_task", "sitting_down"],
|
||||||
|
note: "Testdata: kortere oppgave fungerte bedre.",
|
||||||
|
total_score_percent: 78,
|
||||||
|
demo: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|||||||
@ -123,4 +123,12 @@ describe("evaluator-import", () => {
|
|||||||
expect(result.profile.contains_demo_data).toBe(true);
|
expect(result.profile.contains_demo_data).toBe(true);
|
||||||
expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true);
|
expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("holder veilederens demobrukere separat fra normale brukere", () => {
|
||||||
|
const demoProfiles = listEvaluatorProfiles(true);
|
||||||
|
|
||||||
|
expect(demoProfiles.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(demoProfiles.every((profile) => profile.contains_demo_data)).toBe(true);
|
||||||
|
expect(listEvaluatorProfiles()).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { calculateReportScore, getEntryTime } from "./scoring";
|
import { calculateReportScore, getEntryTime } from "./scoring";
|
||||||
import { summarizeVerification, verifyEntryIntegrity } from "./integrity";
|
import { summarizeVerification, verifyEntryIntegrity } from "./integrity";
|
||||||
|
import { demoEvaluatorProfiles } from "./demoData";
|
||||||
import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types";
|
import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types";
|
||||||
|
|
||||||
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
|
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
|
||||||
|
const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles";
|
||||||
|
const DEMO_EVALUATOR_SEEDED_KEY = "arbeidspuls:demo-evaluator-seeded";
|
||||||
|
|
||||||
export type ImportResult = {
|
export type ImportResult = {
|
||||||
profile: EvaluatorProfile;
|
profile: EvaluatorProfile;
|
||||||
@ -15,8 +18,22 @@ export type ImportResult = {
|
|||||||
matched_report_ids: string[];
|
matched_report_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function readProfiles(): EvaluatorProfile[] {
|
function evaluatorKey(demoMode = false) {
|
||||||
const raw = localStorage.getItem(EVALUATOR_KEY);
|
return demoMode ? DEMO_EVALUATOR_KEY : EVALUATOR_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDemoEvaluatorProfiles() {
|
||||||
|
const existing = readProfiles(true);
|
||||||
|
if (existing.length > 0) return existing;
|
||||||
|
if (localStorage.getItem(DEMO_EVALUATOR_SEEDED_KEY) === "true") return [];
|
||||||
|
const seeded = demoEvaluatorProfiles.map(normalizeProfile);
|
||||||
|
writeProfiles(seeded, true);
|
||||||
|
localStorage.setItem(DEMO_EVALUATOR_SEEDED_KEY, "true");
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProfiles(demoMode = false): EvaluatorProfile[] {
|
||||||
|
const raw = localStorage.getItem(evaluatorKey(demoMode));
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -27,32 +44,33 @@ function readProfiles(): EvaluatorProfile[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeProfiles(profiles: EvaluatorProfile[]) {
|
function writeProfiles(profiles: EvaluatorProfile[], demoMode = false) {
|
||||||
localStorage.setItem(EVALUATOR_KEY, JSON.stringify(profiles));
|
localStorage.setItem(evaluatorKey(demoMode), JSON.stringify(profiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listEvaluatorProfiles() {
|
export function listEvaluatorProfiles(demoMode = false) {
|
||||||
return readProfiles().map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
if (demoMode) ensureDemoEvaluatorProfiles();
|
||||||
|
return readProfiles(demoMode).map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateEvaluatorProfile(profile: EvaluatorProfile) {
|
export function updateEvaluatorProfile(profile: EvaluatorProfile, demoMode = false) {
|
||||||
const profiles = readProfiles();
|
const profiles = readProfiles(demoMode);
|
||||||
const next = profiles.some((item) => item.id === profile.id)
|
const next = profiles.some((item) => item.id === profile.id)
|
||||||
? profiles.map((item) => (item.id === profile.id ? profile : item))
|
? profiles.map((item) => (item.id === profile.id ? profile : item))
|
||||||
: [profile, ...profiles];
|
: [profile, ...profiles];
|
||||||
writeProfiles(next);
|
writeProfiles(next, demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameEvaluatorProfile(profileId: string, displayName: string) {
|
export function renameEvaluatorProfile(profileId: string, displayName: string, demoMode = false) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const profiles = readProfiles().map((profile) =>
|
const profiles = readProfiles(demoMode).map((profile) =>
|
||||||
profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile
|
profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile
|
||||||
);
|
);
|
||||||
writeProfiles(profiles);
|
writeProfiles(profiles, demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteEvaluatorProfile(profileId: string) {
|
export function deleteEvaluatorProfile(profileId: string, demoMode = false) {
|
||||||
writeProfiles(readProfiles().filter((profile) => profile.id !== profileId));
|
writeProfiles(readProfiles(demoMode).filter((profile) => profile.id !== profileId), demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) {
|
export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) {
|
||||||
@ -62,22 +80,24 @@ export function addEvaluatorNote(profileId: string, text: string, relatedReportI
|
|||||||
export function upsertEvaluatorNote(
|
export function upsertEvaluatorNote(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
text: string,
|
text: string,
|
||||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">
|
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">,
|
||||||
|
demoMode = false
|
||||||
) {
|
) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const profiles = readProfiles().map((profile) =>
|
const profiles = readProfiles(demoMode).map((profile) =>
|
||||||
profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile
|
profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile
|
||||||
);
|
);
|
||||||
writeProfiles(profiles);
|
writeProfiles(profiles, demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteEvaluatorNote(
|
export function deleteEvaluatorNote(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">
|
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">,
|
||||||
|
demoMode = false
|
||||||
) {
|
) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const profiles = readProfiles().map((profile) =>
|
const profiles = readProfiles(demoMode).map((profile) =>
|
||||||
profile.id === profileId
|
profile.id === profileId
|
||||||
? {
|
? {
|
||||||
...normalizeProfile(profile),
|
...normalizeProfile(profile),
|
||||||
@ -86,14 +106,14 @@ export function deleteEvaluatorNote(
|
|||||||
}
|
}
|
||||||
: profile
|
: profile
|
||||||
);
|
);
|
||||||
writeProfiles(profiles);
|
writeProfiles(profiles, demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult {
|
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string, demoMode = false): ImportResult {
|
||||||
const parsed = JSON.parse(jsonText);
|
const parsed = JSON.parse(jsonText);
|
||||||
const reports = extractReports(parsed).map(verifyEntryIntegrity);
|
const reports = extractReports(parsed).map(verifyEntryIntegrity);
|
||||||
const demoEntriesDetected = countDemoEntries(reports, parsed);
|
const demoEntriesDetected = countDemoEntries(reports, parsed);
|
||||||
const profiles = readProfiles().map(normalizeProfile);
|
const profiles = readProfiles(demoMode).map(normalizeProfile);
|
||||||
const fingerprint = makeFingerprint(reports);
|
const fingerprint = makeFingerprint(reports);
|
||||||
const target = resolveImportTarget(reports, profiles, targetProfileId);
|
const target = resolveImportTarget(reports, profiles, targetProfileId);
|
||||||
if (target.action === "conflict_requires_manual_resolution") {
|
if (target.action === "conflict_requires_manual_resolution") {
|
||||||
@ -141,7 +161,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
|||||||
evaluator_notes: []
|
evaluator_notes: []
|
||||||
};
|
};
|
||||||
|
|
||||||
updateEvaluatorProfile(profile);
|
updateEvaluatorProfile(profile, demoMode);
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
added,
|
added,
|
||||||
@ -154,25 +174,26 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportEvaluatorBackup() {
|
export function exportEvaluatorBackup(demoMode = false) {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
backup_type: "arbeidsevne-veilederdata",
|
backup_type: "arbeidsevne-veilederdata",
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
profiles: listEvaluatorProfiles()
|
data_mode: demoMode ? "demo" : "normal",
|
||||||
|
profiles: listEvaluatorProfiles(demoMode)
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importEvaluatorBackup(jsonText: string) {
|
export function importEvaluatorBackup(jsonText: string, demoMode = false) {
|
||||||
const parsed = JSON.parse(jsonText);
|
const parsed = JSON.parse(jsonText);
|
||||||
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
|
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
|
||||||
throw new Error("Filen er ikke en gyldig veilederbackup.");
|
throw new Error("Filen er ikke en gyldig veilederbackup.");
|
||||||
}
|
}
|
||||||
const existing = readProfiles();
|
const existing = readProfiles(demoMode);
|
||||||
const merged = [...existing];
|
const merged = [...existing];
|
||||||
for (const profile of parsed.profiles as EvaluatorProfile[]) {
|
for (const profile of parsed.profiles as EvaluatorProfile[]) {
|
||||||
const index = merged.findIndex((item) => item.id === profile.id);
|
const index = merged.findIndex((item) => item.id === profile.id);
|
||||||
@ -186,7 +207,7 @@ export function importEvaluatorBackup(jsonText: string) {
|
|||||||
merged.push(normalizeProfile(profile));
|
merged.push(normalizeProfile(profile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeProfiles(merged);
|
writeProfiles(merged, demoMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) {
|
export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) {
|
||||||
|
|||||||
24
src/i18n.tsx
24
src/i18n.tsx
@ -334,7 +334,7 @@ const en: TranslationTree = {
|
|||||||
notCalculated: "Not calculated",
|
notCalculated: "Not calculated",
|
||||||
unknownWorkplace: "Unknown workplace"
|
unknownWorkplace: "Unknown workplace"
|
||||||
},
|
},
|
||||||
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" },
|
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Advisor", navLabel: "Main navigation" },
|
||||||
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
|
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
|
||||||
demo: {
|
demo: {
|
||||||
enter: "Demo",
|
enter: "Demo",
|
||||||
@ -442,9 +442,9 @@ const en: TranslationTree = {
|
|||||||
backupTitle: "Backup and device changes",
|
backupTitle: "Backup and device changes",
|
||||||
backupBody:
|
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.",
|
"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 a guide",
|
sharingTitle: "Sharing with an advisor",
|
||||||
sharingBody:
|
sharingBody:
|
||||||
"If a guide 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 guide view.",
|
"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.",
|
||||||
sensitiveTitle: "Sensitive information",
|
sensitiveTitle: "Sensitive information",
|
||||||
sensitiveBody:
|
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. Share the JSON file in a way suitable for sensitive information, and only with people who should have access.",
|
||||||
@ -452,8 +452,8 @@ const en: TranslationTree = {
|
|||||||
deleteAllName: "all local reports"
|
deleteAllName: "all local reports"
|
||||||
},
|
},
|
||||||
evaluator: {
|
evaluator: {
|
||||||
eyebrow: "Guide",
|
eyebrow: "Advisor",
|
||||||
title: "Guide review",
|
title: "Advisor review",
|
||||||
backToApp: "Back to app",
|
backToApp: "Back to app",
|
||||||
importJson: "Import JSON",
|
importJson: "Import JSON",
|
||||||
importHelp: "Choose an export from the app. Data is stored locally in this browser.",
|
importHelp: "Choose an export from the app. Data is stored locally in this browser.",
|
||||||
@ -462,14 +462,14 @@ const en: TranslationTree = {
|
|||||||
connectExisting: "Connect to existing person",
|
connectExisting: "Connect to existing person",
|
||||||
autoMatch: "Create new or use automatic match",
|
autoMatch: "Create new or use automatic match",
|
||||||
chooseJson: "Choose JSON file",
|
chooseJson: "Choose JSON file",
|
||||||
exportBackup: "Export guide data",
|
exportBackup: "Export advisor data",
|
||||||
importBackup: "Import guide backup",
|
importBackup: "Import advisor backup",
|
||||||
importedBackup: "Guide backup has been imported.",
|
importedBackup: "Advisor backup has been imported.",
|
||||||
demoDataWarning: "This import contains demo data. Do not use it as real user or health information.",
|
demoDataWarning: "This import contains demo data. Do not use it as real user or health information.",
|
||||||
importStart: "Import a JSON file to start reviewing.",
|
importStart: "Import a JSON file to start reviewing.",
|
||||||
saveName: "Save name",
|
saveName: "Save name",
|
||||||
deletePerson: "Delete user and reports",
|
deletePerson: "Delete user and reports",
|
||||||
deletePersonTitle: "Deletes this user, imported reports, follow-ups and guide notes after confirmation.",
|
deletePersonTitle: "Deletes this user, imported reports, follow-ups and advisor notes after confirmation.",
|
||||||
reportCount: "{count} reports",
|
reportCount: "{count} reports",
|
||||||
personNote: "General note for user",
|
personNote: "General note for user",
|
||||||
personNoteHelp: "This note applies to the person as a whole, not one specific report.",
|
personNoteHelp: "This note applies to the person as a whole, not one specific report.",
|
||||||
@ -486,15 +486,15 @@ const en: TranslationTree = {
|
|||||||
closeDetails: "Close details",
|
closeDetails: "Close details",
|
||||||
reportNote: "Report note",
|
reportNote: "Report note",
|
||||||
followupNote: "Follow-up note",
|
followupNote: "Follow-up note",
|
||||||
noteHelp: "The guide note is stored locally and does not change imported report data.",
|
noteHelp: "The advisor note is stored locally and does not change imported report data.",
|
||||||
deleteNote: "Delete note",
|
deleteNote: "Delete note",
|
||||||
validationError: "Validation error",
|
validationError: "Validation error",
|
||||||
comment: "Comment",
|
comment: "Comment",
|
||||||
evaluatorNote: "Guide note",
|
evaluatorNote: "Advisor note",
|
||||||
followupNoteBadge: "Follow-up note",
|
followupNoteBadge: "Follow-up note",
|
||||||
hasFollowup: "Has follow-up",
|
hasFollowup: "Has follow-up",
|
||||||
hasComment: "Has user comment",
|
hasComment: "Has user comment",
|
||||||
hasEvaluatorNote: "Has guide note",
|
hasEvaluatorNote: "Has advisor note",
|
||||||
scoreValues: "Score values"
|
scoreValues: "Score values"
|
||||||
},
|
},
|
||||||
details: {
|
details: {
|
||||||
|
|||||||
@ -51,14 +51,6 @@ button {
|
|||||||
padding: 10px 2px 14px;
|
padding: 10px 2px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar h1,
|
.topbar h1,
|
||||||
.field-head h2,
|
.field-head h2,
|
||||||
.filters h2,
|
.filters h2,
|
||||||
@ -688,47 +680,6 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-link.active {
|
|
||||||
background: #235b5e;
|
|
||||||
border-color: #235b5e;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-banner {
|
|
||||||
align-items: center;
|
|
||||||
background: #1f4f51;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
||||||
border-radius: 999px;
|
|
||||||
box-shadow: 0 12px 30px rgba(31, 79, 81, 0.22);
|
|
||||||
color: #fff;
|
|
||||||
display: inline-flex;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 950;
|
|
||||||
gap: 0.5rem;
|
|
||||||
letter-spacing: 0;
|
|
||||||
padding: 0.42rem 0.46rem 0.42rem 0.74rem;
|
|
||||||
position: fixed;
|
|
||||||
right: max(12px, env(safe-area-inset-right));
|
|
||||||
top: max(12px, env(safe-area-inset-top));
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-banner button {
|
|
||||||
align-items: center;
|
|
||||||
background: rgba(255, 255, 255, 0.16);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
font: inherit;
|
|
||||||
height: 1.35rem;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
width: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-meta-pill {
|
.demo-meta-pill {
|
||||||
background: #fbecd7;
|
background: #fbecd7;
|
||||||
color: #7d4118;
|
color: #7d4118;
|
||||||
@ -1633,7 +1584,10 @@ textarea {
|
|||||||
border: 1px solid #ccd7d4;
|
border: 1px solid #ccd7d4;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #235b5e;
|
color: #235b5e;
|
||||||
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 850;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.44rem 0.7rem;
|
padding: 0.44rem 0.7rem;
|
||||||
@ -1653,6 +1607,19 @@ textarea {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-link.active {
|
||||||
|
background: #235b5e;
|
||||||
|
border-color: #235b5e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link.active:hover,
|
||||||
|
.footer-link.active:focus-visible {
|
||||||
|
background: #1f4f51;
|
||||||
|
border-color: #1f4f51;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.app-footer svg {
|
.app-footer svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user