Add Ukrainian translation
This commit is contained in:
parent
7dec8eb885
commit
f6546bb3ff
@ -51,9 +51,11 @@ The user owns their data locally in the browser and actively shares it by export
|
||||
- advisor notes per user, report and follow-up
|
||||
- fullscreen graph analysis for advisors
|
||||
- local report integrity checks
|
||||
- Norwegian and English user interface
|
||||
- Norwegian, English and Ukrainian user interface
|
||||
- installable as a PWA/WebApp on phones and tablets
|
||||
|
||||
The language selector only translates static UI text and display labels. Report content, comments, advisor notes and imported JSON are never changed.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@ -325,7 +327,7 @@ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(),
|
||||
- JSON import/export
|
||||
- local advisor view
|
||||
- optional FastAPI/SQLite backend for encrypted one-time sharing
|
||||
- Norwegian/English UI
|
||||
- Norwegian/English/Ukrainian UI
|
||||
|
||||
### Production
|
||||
|
||||
|
||||
@ -51,9 +51,11 @@ Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å ekspo
|
||||
- veiledernotater per bruker, rapport og oppfølging
|
||||
- fullskjerm grafanalyse for veileder
|
||||
- lokal integritetskontroll av rapporter
|
||||
- norsk og engelsk brukergrensesnitt
|
||||
- norsk, engelsk og ukrainsk brukergrensesnitt
|
||||
- kan installeres som PWA/WebApp på telefon og nettbrett
|
||||
|
||||
Språkvalget oversetter bare statiske tekster og visningsetiketter. Rapportinnhold, kommentarer, veiledernotater og importert JSON endres ikke.
|
||||
|
||||
---
|
||||
|
||||
## Skjermbilder
|
||||
@ -325,7 +327,7 @@ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(),
|
||||
- JSON import/eksport
|
||||
- lokal veiledervisning
|
||||
- valgfri FastAPI/SQLite-backend for kryptert engangsdeling
|
||||
- norsk/engelsk UI
|
||||
- norsk/engelsk/ukrainsk UI
|
||||
|
||||
### Produksjon
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "arbeidspuls-v9";
|
||||
const CACHE_NAME = "arbeidspuls-v10";
|
||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
||||
87
src/App.tsx
87
src/App.tsx
@ -71,6 +71,8 @@ import {
|
||||
} from "./feedback";
|
||||
import {
|
||||
I18nProvider,
|
||||
localeForLanguage,
|
||||
readStoredLanguage,
|
||||
translateScoreBand,
|
||||
translateScoreBandDescription,
|
||||
translateVerificationStatus,
|
||||
@ -129,7 +131,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.3.0";
|
||||
const APP_VERSION = "1.3.1";
|
||||
const COMMERCIAL_LICENSE_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Arbeidspuls/src/branch/main/about-commercial-license.md";
|
||||
|
||||
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
|
||||
@ -137,14 +139,23 @@ const limitationOptions = Object.keys(limitationLabels) as Limitation[];
|
||||
const accommodationOptions = Object.keys(accommodationLabels) as Accommodation[];
|
||||
const delayedSymptomOptions = Object.keys(delayedSymptomLabels) as DelayedSymptom[];
|
||||
const visibleStatusOptions: ReportStatus[] = ["kan_fortsette", "trenger_pause", "trenger_enklere_oppgave", "bør_stoppe"];
|
||||
const trendMetrics: TrendMetric[] = [
|
||||
{ key: "total_score_percent", label: "Totalscore", short: "T", color: "#235b5e" },
|
||||
{ key: "work_ability", label: "Arbeidsevne", short: "A", color: "#307f78" },
|
||||
{ key: "energy_level", label: "Energi", short: "E", color: "#b2642a" },
|
||||
{ key: "mental_clarity", label: "Klarhet", short: "K", color: "#4f5c9c" },
|
||||
{ key: "symptom_burden", label: "Symptomer", short: "S", color: "#b23b4a" },
|
||||
{ key: "effort_strain", label: "Belastning", short: "B", color: "#6b5a3a" }
|
||||
];
|
||||
const trendMetricDefinitions = [
|
||||
{ key: "total_score_percent", labelKey: "metricTotalScore", shortKey: "metricTotalScoreShort", color: "#235b5e" },
|
||||
{ key: "work_ability", labelKey: "metricWorkAbility", shortKey: "metricWorkAbilityShort", color: "#307f78" },
|
||||
{ key: "energy_level", labelKey: "metricEnergy", shortKey: "metricEnergyShort", color: "#b2642a" },
|
||||
{ key: "mental_clarity", labelKey: "metricClarity", shortKey: "metricClarityShort", color: "#4f5c9c" },
|
||||
{ key: "symptom_burden", labelKey: "metricSymptoms", shortKey: "metricSymptomsShort", color: "#b23b4a" },
|
||||
{ key: "effort_strain", labelKey: "metricStrain", shortKey: "metricStrainShort", color: "#6b5a3a" }
|
||||
] as const;
|
||||
|
||||
function localizedTrendMetrics(t: ReturnType<typeof useI18n>["t"]): TrendMetric[] {
|
||||
return trendMetricDefinitions.map((metric) => ({
|
||||
key: metric.key,
|
||||
label: t(`graphModal.${metric.labelKey}`),
|
||||
short: t(`graphModal.${metric.shortKey}`),
|
||||
color: metric.color
|
||||
}));
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -609,6 +620,16 @@ function AppFooter({
|
||||
>
|
||||
{t("footer.english")} 🇬🇧
|
||||
</button>
|
||||
<span className="footer-language-separator">|</span>
|
||||
<button
|
||||
aria-current={language === "uk-UA"}
|
||||
aria-label={t("footer.switchToUkrainian")}
|
||||
className="footer-language-button"
|
||||
onClick={() => setLanguage("uk-UA")}
|
||||
type="button"
|
||||
>
|
||||
{t("footer.ukrainian")} 🇺🇦
|
||||
</button>
|
||||
</div>
|
||||
<div className="footer-credit">
|
||||
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
|
||||
@ -1511,7 +1532,7 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt
|
||||
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(entries), period), [entries, period]);
|
||||
const periodAssessment = showScoreBands ? calculatePeriodWorkAbilityAssessment(reports) : null;
|
||||
|
||||
const metrics = trendMetrics;
|
||||
const metrics = localizedTrendMetrics(t);
|
||||
|
||||
if (reports.length < 2) {
|
||||
return (
|
||||
@ -1724,6 +1745,7 @@ function FullscreenGraphModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const metrics = localizedTrendMetrics(t);
|
||||
const [period, setPeriod] = useState(initialPeriod);
|
||||
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({ total_score_percent: "dim", followup_response: "normal" });
|
||||
const [inspect, setInspect] = useState<{ x: number; report: Report | null } | null>(null);
|
||||
@ -1825,7 +1847,7 @@ function FullscreenGraphModal({
|
||||
<p>{t("graphModal.visibleHelp")}</p>
|
||||
</div>
|
||||
<div className="legend interactive">
|
||||
{trendMetrics.map((metric) => {
|
||||
{metrics.map((metric) => {
|
||||
const state = visibility[metric.key] ?? "normal";
|
||||
return (
|
||||
<button className={`legend-chip ${state}`} key={metric.key} onClick={() => cycleVisibility(metric.key)} type="button">
|
||||
@ -1884,7 +1906,7 @@ function FullscreenGraphModal({
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{trendMetrics.flatMap((metric) => {
|
||||
{metrics.flatMap((metric) => {
|
||||
const state = visibility[metric.key] ?? "normal";
|
||||
if (state === "hidden") return [];
|
||||
const points = reports
|
||||
@ -1921,7 +1943,7 @@ function FullscreenGraphModal({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{trendMetrics.flatMap((metric) => {
|
||||
{metrics.flatMap((metric) => {
|
||||
const state = visibility[metric.key] ?? "normal";
|
||||
if (state === "hidden") return [];
|
||||
return reports
|
||||
@ -2004,6 +2026,7 @@ function GraphInspectTooltip({
|
||||
visibility: Record<string, LineVisibility>;
|
||||
}) {
|
||||
const { labels, t } = useI18n();
|
||||
const metrics = localizedTrendMetrics(t);
|
||||
if (!report) {
|
||||
return (
|
||||
<aside className="graph-inspect-panel">
|
||||
@ -2027,7 +2050,7 @@ function GraphInspectTooltip({
|
||||
</p>
|
||||
)}
|
||||
<dl>
|
||||
{trendMetrics
|
||||
{metrics
|
||||
.filter((metric) => (visibility[metric.key] ?? "normal") !== "hidden")
|
||||
.map((metric) => {
|
||||
const value = normalizeReportMetric(report, metric.key);
|
||||
@ -2198,7 +2221,7 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
|
||||
setImportMessage("");
|
||||
} catch (error) {
|
||||
setPendingImport(null);
|
||||
setImportMessage(error instanceof Error ? error.message : "Importfilen kunne ikke leses.");
|
||||
setImportMessage(error instanceof Error ? error.message : t("evaluator.importReadError"));
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
@ -2640,18 +2663,18 @@ function EvaluatorApp() {
|
||||
try {
|
||||
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 actionText =
|
||||
result.target_resolution === "matched_existing_person"
|
||||
? "Importen ble knyttet til eksisterende bruker."
|
||||
: "Opprettet ny bruker.";
|
||||
setMessage(
|
||||
`${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.${result.demo_entries_detected > 0 ? ` ADVARSEL: Importen inneholder ${result.demo_entries_detected} demodata-oppføringer.` : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
|
||||
);
|
||||
const actionText = result.target_resolution === "matched_existing_person" ? t("evaluator.importMatched") : t("evaluator.importCreated");
|
||||
const parts = [t("evaluator.importResult", { action: actionText, added: result.added, skipped: result.skipped })];
|
||||
if (result.demo_entries_detected > 0) {
|
||||
parts.push(t("evaluator.demoImportResultWarning", { count: result.demo_entries_detected }));
|
||||
}
|
||||
if (flagged > 0) parts.push(t("evaluator.verificationWarning"));
|
||||
setMessage(parts.join(" "));
|
||||
setDisplayName("");
|
||||
setTargetId("");
|
||||
refresh(result.profile.id);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Import feilet.");
|
||||
setMessage(error instanceof Error ? error.message : t("evaluator.importFailed"));
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
@ -2664,10 +2687,14 @@ function EvaluatorApp() {
|
||||
const text = await file.text();
|
||||
const demoEntries = countEvaluatorBackupDemoEntries(text);
|
||||
importEvaluatorBackup(text, demoMode);
|
||||
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
|
||||
setMessage(
|
||||
[t("evaluator.importedBackup"), demoEntries > 0 ? t("evaluator.demoBackupWarning", { count: demoEntries }) : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
|
||||
setMessage(error instanceof Error ? error.message : t("evaluator.backupFailed"));
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
@ -3188,7 +3215,7 @@ function ReportDetailSections({ report, followups }: { report: Report; followups
|
||||
<Detail label={t("details.status")} value={labels.statusLabels[report.status]} />
|
||||
<Detail label={t("details.totalScore")} value={score === null ? t("common.notCalculated") : `${score}%`} />
|
||||
<Detail label={t("details.functionLevel")} value={translateScoreBand(band, t)} />
|
||||
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? "Totalscore mangler eller kan ikke beregnes."} />
|
||||
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? t("details.scoreUnavailable")} />
|
||||
<Detail label={t("details.important")} value={t("scoring.disclaimer")} />
|
||||
</DetailSection>
|
||||
<DetailSection title={t("details.coreValues")}>
|
||||
@ -3267,7 +3294,7 @@ function ReportTechnicalDetails({ report }: { report: Report }) {
|
||||
<p>{t("details.technicalDescription")}</p>
|
||||
<DetailSection title={t("details.technical")}>
|
||||
<Detail label={t("details.reportId")} value={report.id} />
|
||||
<Detail label="Type" value={report.type} />
|
||||
<Detail label={t("details.type")} value={report.type} />
|
||||
<Detail label={t("details.created")} value={formatIsoDateTime(report.created_at)} />
|
||||
{report.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(report.updated_at)} />}
|
||||
<Detail label={t("details.reportDate")} value={report.report_date} />
|
||||
@ -3275,7 +3302,7 @@ function ReportTechnicalDetails({ report }: { report: Report }) {
|
||||
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(report.integrity?.verification_status, t)} />
|
||||
<Detail label={t("details.explanation")} value={report.integrity?.verification_reason || verificationTooltip(report.integrity?.verification_status)} />
|
||||
<Detail label={t("details.algorithm")} value={report.integrity?.algorithm || t("common.notProvided")} />
|
||||
<Detail label="Canonicalization" value={report.integrity?.canonicalization || t("common.notProvided")} />
|
||||
<Detail label={t("details.canonicalization")} value={report.integrity?.canonicalization || t("common.notProvided")} />
|
||||
<Detail label={t("details.signed")} value={report.integrity?.signed_at ? formatIsoDateTime(report.integrity.signed_at) : t("common.notProvided")} />
|
||||
<Detail label={t("details.payloadHash")} value={report.integrity?.payload_hash_sha256 || t("common.notProvided")} />
|
||||
</DetailSection>
|
||||
@ -3294,7 +3321,7 @@ function FollowupTechnicalDetails({ followup }: { followup: DelayedFollowup }) {
|
||||
<DetailSection title={t("details.technical")}>
|
||||
<Detail label={t("details.followupId")} value={followup.id} />
|
||||
<Detail label={t("details.connectedReport")} value={findParentId(followup) || t("common.notSelected")} />
|
||||
<Detail label="Type" value={followup.type} />
|
||||
<Detail label={t("details.type")} value={followup.type} />
|
||||
<Detail label={t("details.created")} value={formatIsoDateTime(followup.created_at)} />
|
||||
{followup.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(followup.updated_at)} />}
|
||||
<Detail label={t("details.reportDate")} value={followup.report_date} />
|
||||
@ -3379,7 +3406,7 @@ function getFollowupNote(notes: EvaluatorProfile["evaluator_notes"], followupId:
|
||||
}
|
||||
|
||||
function currentDateLocale() {
|
||||
return localStorage.getItem("arbeidspuls.language") === "en" ? "en-GB" : "nb-NO";
|
||||
return localeForLanguage(readStoredLanguage());
|
||||
}
|
||||
|
||||
export function formatDateTime(entry: StoredEntry) {
|
||||
|
||||
@ -91,6 +91,12 @@ describe("oppfølgingsrespons", () => {
|
||||
expect(formatDateTime(report)).toContain("Onsdag");
|
||||
});
|
||||
|
||||
it("bruker ukrainsk ukedag når språk er ukrainsk", () => {
|
||||
localStorage.setItem("arbeidspuls.language", "uk-UA");
|
||||
|
||||
expect(formatDateTime(report)).toContain("Середа");
|
||||
});
|
||||
|
||||
it("lager arbeidstidsband og håndterer arbeidstid over midnatt", () => {
|
||||
const daytime = { ...report, id: "rapport-dag", work_start_time: "08:00", work_end_time: "10:00" };
|
||||
const overnight = { ...report, id: "rapport-natt", work_start_time: "23:00", work_end_time: "01:00" };
|
||||
@ -108,6 +114,7 @@ describe("oppfølgingsrespons", () => {
|
||||
it("har språktilpassede datoplaceholdere for graffilter", () => {
|
||||
expect(datePlaceholderForLanguage("nb-NO")).toBe("dd/mm/åååå");
|
||||
expect(datePlaceholderForLanguage("en")).toBe("dd/mm/yyyy");
|
||||
expect(datePlaceholderForLanguage("uk-UA")).toBe("дд/мм/рррр");
|
||||
});
|
||||
|
||||
it("runder grafstart ned til 00:00 på første synlige dato", () => {
|
||||
|
||||
50
src/i18n.test.ts
Normal file
50
src/i18n.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { labelsByLanguage, localeForLanguage, missingTranslationKeys, readStoredLanguage, supportedLanguages } from "./i18n";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear()
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ukrainian language support", () => {
|
||||
it("registers Ukrainian with its native name and flag", () => {
|
||||
expect(supportedLanguages).toContainEqual({
|
||||
code: "uk-UA",
|
||||
nativeName: "Українська",
|
||||
flag: "🇺🇦"
|
||||
});
|
||||
});
|
||||
|
||||
it("has every translation key available in Ukrainian and English", () => {
|
||||
expect(missingTranslationKeys("uk-UA")).toEqual([]);
|
||||
expect(missingTranslationKeys("en")).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists Ukrainian and keeps Norwegian as the default", () => {
|
||||
expect(readStoredLanguage()).toBe("nb-NO");
|
||||
localStorage.setItem("arbeidspuls.language", "uk-UA");
|
||||
expect(readStoredLanguage()).toBe("uk-UA");
|
||||
expect(localeForLanguage(readStoredLanguage())).toBe("uk-UA");
|
||||
});
|
||||
|
||||
it("changes only display labels and leaves stored report content untouched", () => {
|
||||
const report = {
|
||||
status: "kan_fortsette",
|
||||
comment: "This user comment must stay exactly as written.",
|
||||
advisorNote: "Veiledernotat skal ikke oversettes."
|
||||
} as const;
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
expect(labelsByLanguage["uk-UA"].statusLabels[report.status]).toBe("Може продовжувати");
|
||||
expect(JSON.stringify(report)).toBe(serialized);
|
||||
expect(report.comment).toBe("This user comment must stay exactly as written.");
|
||||
expect(report.advisorNote).toBe("Veiledernotat skal ikke oversettes.");
|
||||
});
|
||||
});
|
||||
138
src/i18n.tsx
138
src/i18n.tsx
@ -26,12 +26,33 @@ import type {
|
||||
VerificationStatus,
|
||||
WorseThanBefore
|
||||
} from "./types";
|
||||
import {
|
||||
ukAccommodationLabels,
|
||||
ukCategoryTooltipTexts,
|
||||
ukDelayedSymptomLabels,
|
||||
ukLimitationLabels,
|
||||
ukNegativeScale,
|
||||
ukPositiveScale,
|
||||
ukRecoveryLabels,
|
||||
ukStatusLabels,
|
||||
ukTaskCompletionLabels,
|
||||
ukTaskTypeLabels,
|
||||
ukTooltipTexts,
|
||||
ukWorseLabels
|
||||
} from "./labels.uk";
|
||||
import { uk } from "./i18n.uk";
|
||||
|
||||
export type Language = "nb-NO" | "en";
|
||||
export type Language = "nb-NO" | "en" | "uk-UA";
|
||||
|
||||
const LANGUAGE_KEY = "arbeidspuls.language";
|
||||
|
||||
type TranslationTree = { [key: string]: string | TranslationTree };
|
||||
export type TranslationTree = { [key: string]: string | TranslationTree };
|
||||
|
||||
export const supportedLanguages = [
|
||||
{ code: "nb-NO", nativeName: "Norsk", flag: "🇳🇴" },
|
||||
{ code: "en", nativeName: "English", flag: "🇬🇧" },
|
||||
{ code: "uk-UA", nativeName: "Українська", flag: "🇺🇦" }
|
||||
] as const satisfies ReadonlyArray<{ code: Language; nativeName: string; flag: string }>;
|
||||
|
||||
const nb = {
|
||||
common: {
|
||||
@ -66,8 +87,10 @@ const nb = {
|
||||
footer: {
|
||||
norwegian: "Norsk",
|
||||
english: "English",
|
||||
ukrainian: "Українська",
|
||||
switchToNorwegian: "Bytt språk til norsk",
|
||||
switchToEnglish: "Switch language to English",
|
||||
switchToUkrainian: "Bytt språk til ukrainsk",
|
||||
developedBy: "Utviklet av Rolfsvaag Datateknikk med",
|
||||
heart: "hjerte",
|
||||
languageChoice: "Språkvalg",
|
||||
@ -340,7 +363,16 @@ const nb = {
|
||||
hasFollowup: "Har oppfølging",
|
||||
hasComment: "Har brukerkommentar",
|
||||
hasEvaluatorNote: "Har veiledernotat",
|
||||
scoreValues: "Scoreverdier"
|
||||
scoreValues: "Scoreverdier",
|
||||
importReadError: "Importfilen kunne ikke leses.",
|
||||
importMatched: "Importen ble knyttet til eksisterende bruker.",
|
||||
importCreated: "Opprettet ny bruker.",
|
||||
importFailed: "Import feilet.",
|
||||
backupFailed: "Backup kunne ikke importeres.",
|
||||
demoBackupWarning: "ADVARSEL: Backupen inneholder {count} demodata-oppføringer.",
|
||||
importResult: "{action} {added} nye rapporter, {skipped} duplikater hoppet over.",
|
||||
demoImportResultWarning: "ADVARSEL: Importen inneholder {count} demodata-oppføringer.",
|
||||
verificationWarning: "Noen rapporter kunne ikke verifiseres."
|
||||
},
|
||||
details: {
|
||||
summary: "Kort oppsummering",
|
||||
@ -389,6 +421,9 @@ const nb = {
|
||||
algorithm: "Algoritme",
|
||||
signed: "Signert",
|
||||
payloadHash: "Payload-hash",
|
||||
type: "Type",
|
||||
canonicalization: "Canonicalization",
|
||||
scoreUnavailable: "Totalscore mangler eller kan ikke beregnes.",
|
||||
worse: "Verre enn før",
|
||||
delayedSymptoms: "Forsinkede symptomer",
|
||||
recovery: "Restitusjon",
|
||||
@ -425,7 +460,19 @@ const nb = {
|
||||
followupResponseDisclaimer: "Dette er en visuell ettereffektindikator, ikke en ny totalscore.",
|
||||
noFollowups: "Ingen oppfølginger for valgt rapport.",
|
||||
followupDelay: "Forsinkelse: {hours} timer",
|
||||
followupEffect: "Ettereffekt: {score}% fra rapportens {parent}%"
|
||||
followupEffect: "Ettereffekt: {score}% fra rapportens {parent}%",
|
||||
metricTotalScore: "Totalscore",
|
||||
metricWorkAbility: "Arbeidsevne",
|
||||
metricEnergy: "Energi",
|
||||
metricClarity: "Klarhet",
|
||||
metricSymptoms: "Symptomer",
|
||||
metricStrain: "Belastning",
|
||||
metricTotalScoreShort: "T",
|
||||
metricWorkAbilityShort: "A",
|
||||
metricEnergyShort: "E",
|
||||
metricClarityShort: "K",
|
||||
metricSymptomsShort: "S",
|
||||
metricStrainShort: "B"
|
||||
},
|
||||
scoring: {
|
||||
disclaimer:
|
||||
@ -478,8 +525,10 @@ const en: TranslationTree = {
|
||||
footer: {
|
||||
norwegian: "Norsk",
|
||||
english: "English",
|
||||
ukrainian: "Українська",
|
||||
switchToNorwegian: "Bytt språk til norsk",
|
||||
switchToEnglish: "Switch language to English",
|
||||
switchToUkrainian: "Switch language to Ukrainian",
|
||||
developedBy: "Developed by Rolfsvaag Datateknikk with",
|
||||
heart: "heart",
|
||||
languageChoice: "Language choice",
|
||||
@ -751,7 +800,16 @@ const en: TranslationTree = {
|
||||
hasFollowup: "Has follow-up",
|
||||
hasComment: "Has user comment",
|
||||
hasEvaluatorNote: "Has advisor note",
|
||||
scoreValues: "Score values"
|
||||
scoreValues: "Score values",
|
||||
importReadError: "The import file could not be read.",
|
||||
importMatched: "The import was connected to an existing user.",
|
||||
importCreated: "A new user was created.",
|
||||
importFailed: "Import failed.",
|
||||
backupFailed: "The backup could not be imported.",
|
||||
demoBackupWarning: "WARNING: The backup contains {count} demo data entries.",
|
||||
importResult: "{action} {added} new reports, {skipped} duplicates skipped.",
|
||||
demoImportResultWarning: "WARNING: The import contains {count} demo data entries.",
|
||||
verificationWarning: "Some reports could not be verified."
|
||||
},
|
||||
details: {
|
||||
summary: "Short summary",
|
||||
@ -800,6 +858,9 @@ const en: TranslationTree = {
|
||||
algorithm: "Algorithm",
|
||||
signed: "Signed",
|
||||
payloadHash: "Payload hash",
|
||||
type: "Type",
|
||||
canonicalization: "Canonicalization",
|
||||
scoreUnavailable: "The total score is missing or could not be calculated.",
|
||||
worse: "Worse than before",
|
||||
delayedSymptoms: "Delayed symptoms",
|
||||
recovery: "Recovery",
|
||||
@ -836,7 +897,19 @@ const en: TranslationTree = {
|
||||
followupResponseDisclaimer: "This is a visual after-effect indicator, not a new total score.",
|
||||
noFollowups: "No follow-ups for the selected report.",
|
||||
followupDelay: "Delay: {hours} hours",
|
||||
followupEffect: "After-effect: {score}% from report {parent}%"
|
||||
followupEffect: "After-effect: {score}% from report {parent}%",
|
||||
metricTotalScore: "Total score",
|
||||
metricWorkAbility: "Work ability",
|
||||
metricEnergy: "Energy",
|
||||
metricClarity: "Clarity",
|
||||
metricSymptoms: "Symptoms",
|
||||
metricStrain: "Strain",
|
||||
metricTotalScoreShort: "T",
|
||||
metricWorkAbilityShort: "A",
|
||||
metricEnergyShort: "E",
|
||||
metricClarityShort: "C",
|
||||
metricSymptomsShort: "S",
|
||||
metricStrainShort: "S"
|
||||
},
|
||||
scoring: {
|
||||
disclaimer:
|
||||
@ -993,6 +1066,20 @@ export const labelsByLanguage = {
|
||||
B: "Effort/strain",
|
||||
T: "Total score"
|
||||
}
|
||||
},
|
||||
"uk-UA": {
|
||||
statusLabels: ukStatusLabels,
|
||||
taskTypeLabels: ukTaskTypeLabels,
|
||||
limitationLabels: ukLimitationLabels,
|
||||
accommodationLabels: ukAccommodationLabels,
|
||||
taskCompletionLabels: ukTaskCompletionLabels,
|
||||
worseLabels: ukWorseLabels,
|
||||
delayedSymptomLabels: ukDelayedSymptomLabels,
|
||||
recoveryLabels: ukRecoveryLabels,
|
||||
positiveScale: ukPositiveScale,
|
||||
negativeScale: ukNegativeScale,
|
||||
tooltipTexts: ukTooltipTexts,
|
||||
categoryTooltipTexts: ukCategoryTooltipTexts
|
||||
}
|
||||
} as const;
|
||||
|
||||
@ -1018,7 +1105,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
setLanguage,
|
||||
labels: labelsByLanguage[language],
|
||||
t: (key, params) => interpolate(readTranslation(language, key), params),
|
||||
locale: language === "en" ? "en-GB" : "nb-NO"
|
||||
locale: localeForLanguage(language)
|
||||
};
|
||||
}, [language]);
|
||||
|
||||
@ -1051,31 +1138,58 @@ export function translateScoreBand(
|
||||
export function translateScoreBandDescription(band: { label: string; description: string } | null | undefined, language: Language) {
|
||||
if (!band || language === "nb-NO") return band?.description;
|
||||
const key = scoreBandKeyFromNorwegianLabel(band.label);
|
||||
const descriptions: Record<string, string> = {
|
||||
const descriptions: Record<Exclude<Language, "nb-NO">, Record<string, string>> = {
|
||||
en: {
|
||||
exceptionallyHigh: "The report suggests very good function in the current work situation, above what can normally be expected.",
|
||||
expected: "The report is within an expected/good function level for the current work situation.",
|
||||
moderatelyReduced: "The report suggests a noticeable reduction in work ability, energy, symptom burden or sustainability.",
|
||||
significantlyReduced: "The report suggests clearly limited function and/or high strain when completing the activity.",
|
||||
veryLow: "The report suggests very low current function, high strain or that the activity may not be sustainable."
|
||||
},
|
||||
"uk-UA": {
|
||||
exceptionallyHigh: "Звіт свідчить про дуже добрий рівень функціонування в поточній робочій ситуації, вищий за звичайно очікуваний.",
|
||||
expected: "Звіт відповідає очікуваному або доброму рівню функціонування в поточній робочій ситуації.",
|
||||
moderatelyReduced: "Звіт свідчить про помітне зниження працездатності, енергії, переносимості симптомів або витривалості.",
|
||||
significantlyReduced: "Звіт свідчить про суттєво обмежене функціонування та/або високе навантаження під час виконання роботи.",
|
||||
veryLow: "Звіт свідчить про дуже низький поточний рівень функціонування, високе навантаження або нестійкість такої діяльності."
|
||||
}
|
||||
};
|
||||
return key ? descriptions[key] : band.description;
|
||||
return key ? descriptions[language][key] : band.description;
|
||||
}
|
||||
|
||||
export function datePlaceholderForLanguage(language: Language) {
|
||||
return readTranslation(language, "overview.datePlaceholder");
|
||||
}
|
||||
|
||||
function readStoredLanguage(): Language {
|
||||
export function localeForLanguage(language: Language) {
|
||||
if (language === "en") return "en-GB";
|
||||
return language;
|
||||
}
|
||||
|
||||
export function readStoredLanguage(): Language {
|
||||
const stored = localStorage.getItem(LANGUAGE_KEY);
|
||||
return stored === "en" || stored === "nb-NO" ? stored : "nb-NO";
|
||||
return stored === "en" || stored === "nb-NO" || stored === "uk-UA" ? stored : "nb-NO";
|
||||
}
|
||||
|
||||
function readTranslation(language: Language, key: string) {
|
||||
const translated = lookup(language === "en" ? en : nb, key);
|
||||
const translated = lookup(language === "en" ? en : language === "uk-UA" ? uk : nb, key);
|
||||
const fallback = lookup(nb, key);
|
||||
return typeof translated === "string" ? translated : typeof fallback === "string" ? fallback : key;
|
||||
}
|
||||
|
||||
export function missingTranslationKeys(language: Exclude<Language, "nb-NO">) {
|
||||
const referenceKeys = flattenTranslationKeys(nb);
|
||||
const translatedKeys = new Set(flattenTranslationKeys(language === "en" ? en : uk));
|
||||
return referenceKeys.filter((key) => !translatedKeys.has(key));
|
||||
}
|
||||
|
||||
function flattenTranslationKeys(tree: TranslationTree, prefix = ""): string[] {
|
||||
return Object.entries(tree).flatMap(([key, value]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
return typeof value === "string" ? [path] : flattenTranslationKeys(value, path);
|
||||
});
|
||||
}
|
||||
|
||||
function lookup(tree: TranslationTree, key: string): string | TranslationTree | undefined {
|
||||
return key.split(".").reduce<string | TranslationTree | undefined>((current, part) => {
|
||||
if (!current || typeof current === "string") return undefined;
|
||||
|
||||
438
src/i18n.uk.ts
Normal file
438
src/i18n.uk.ts
Normal file
@ -0,0 +1,438 @@
|
||||
import type { TranslationTree } from "./i18n";
|
||||
|
||||
export const uk: TranslationTree = {
|
||||
"common": {
|
||||
"cancel": "Скасувати",
|
||||
"close": "Закрити",
|
||||
"copied": "Скопійовано",
|
||||
"delete": "Видалити",
|
||||
"save": "Зберегти",
|
||||
"notSelected": "Не вибрано",
|
||||
"notProvided": "Не вказано",
|
||||
"notCalculated": "Не розраховано",
|
||||
"unknownWorkplace": "Невідоме місце роботи"
|
||||
},
|
||||
"app": {
|
||||
"eyebrow": "Локальна самооцінка",
|
||||
"title": "Arbeidspuls",
|
||||
"evaluator": "Консультант",
|
||||
"navLabel": "Основна навігація"
|
||||
},
|
||||
"nav": {
|
||||
"register": "Реєстрація",
|
||||
"followup": "Подальший запис",
|
||||
"overview": "Огляд",
|
||||
"privacy": "Конфіденційність"
|
||||
},
|
||||
"demo": {
|
||||
"enter": "Демо",
|
||||
"exit": "Вийти з демонстрації",
|
||||
"label": "Демо дані",
|
||||
"notice": "Ви в демонстраційному режимі. Ці дані є окремими від реальних звітів, а експорт/імпорт застосовується лише до демонстраційних даних."
|
||||
},
|
||||
"footer": {
|
||||
"norwegian": "Norsk",
|
||||
"english": "English",
|
||||
"ukrainian": "Українська",
|
||||
"switchToNorwegian": "Перейти на норвезьку мову",
|
||||
"switchToEnglish": "Перейти на англійську мову",
|
||||
"switchToUkrainian": "Перейти на українську мову",
|
||||
"developedBy": "Розроблено Rolfsvaag Datateknikk з",
|
||||
"heart": "серце",
|
||||
"languageChoice": "Вибір мови",
|
||||
"sourceCode": "Вихідний код v.{version}",
|
||||
"commercialLicense": "Комерційна ліцензія"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Відгук",
|
||||
"buttonAria": "Надіслати анонімний відгук",
|
||||
"title": "Надіслати відгук",
|
||||
"stepOne": "1 із 2: Опишіть відгук",
|
||||
"stepTwo": "2 з 2: підтвердити публічне подання",
|
||||
"intro": "Після успішної перевірки відгук буде створено як публічне звернення.",
|
||||
"typeLabel": "Тип",
|
||||
"typeBug": "Помилка чи проблема",
|
||||
"typeFeature": "Пропозиція щодо покращення",
|
||||
"typeDocs": "Документація або текст",
|
||||
"typeTask": "Загальне завдання",
|
||||
"typeOther": "Інше",
|
||||
"questionsNotice": "Запитання слід надсилати безпосередньо власнику проекту, а не як публічний відгук про проект.",
|
||||
"areaLabel": "Область",
|
||||
"technicalAreas": "Технічні категорії",
|
||||
"areaReporting": "Реєстрація звіту",
|
||||
"areaFollowup": "Подальший запис",
|
||||
"areaGraph": "Огляд і графік",
|
||||
"areaAdvisor": "Перегляд консультанта",
|
||||
"areaImportExport": "Імпорт, експорт і обмін",
|
||||
"areaI18n": "Мова і переклад",
|
||||
"areaPrivacy": "Конфіденційність і локальне зберігання",
|
||||
"areaUi": "Компонування та зручність використання",
|
||||
"areaAccessibility": "Доступність",
|
||||
"areaLegal": "Юридичний або ліцензійний",
|
||||
"areaApi": "API",
|
||||
"areaBackend": "Бекенд/сервер",
|
||||
"areaDeployment": "Розгортання",
|
||||
"areaPwa": "Встановити як додаток/PWA",
|
||||
"areaSecurity": "Безпека",
|
||||
"areaPerformance": "Продуктивність",
|
||||
"legalNotice": "Спеціальне ліцензування або комерційне використання має вирішуватися безпосередньо з власником проекту.",
|
||||
"commercialLicenseLink": "Прочитайте про спеціальні ліцензії та комерційне використання.",
|
||||
"messageLabel": "Опис",
|
||||
"messagePlaceholder": "Опишіть, що сталося, що ви очікували або що ви пропонуєте.",
|
||||
"validationTitle": "Вимоги до опису",
|
||||
"validationMinChars": "Напишіть не менше 30 символів.",
|
||||
"validationMaxChars": "Максимум 4000 символів.",
|
||||
"validationMinWords": "Напишіть не менше 5 слів.",
|
||||
"validationMinLetters": "Напишіть не менше 15 букв.",
|
||||
"validationMeaningful": "Текст повинен містити змістовний зміст, а не лише цифри чи символи.",
|
||||
"validationRepetition": "Текст не може складатися з повторів.",
|
||||
"validationContact": "Видаліть електронну адресу, номер телефону, IP-адресу або довгі номерні послідовності.",
|
||||
"validationType": "Виберіть саме один вид.",
|
||||
"validationArea": "Виберіть принаймні одну область.",
|
||||
"publicNotice": "Зворотній зв'язок може бути створений як загальнодоступні проблеми в списку проблем проекту. Не вводьте конфіденційну інформацію, не повідомляйте про вміст, імена людей чи організацій, адреси електронної пошти, номери телефонів чи іншу інформацію, яка може ідентифікувати вас чи інших осіб.",
|
||||
"purposeNotice": "Відгуки використовуються лише для покращення Arbeidspuls.",
|
||||
"consent": "Я підтверджую, що відгук не містить конфіденційної інформації чи інформації, яку можна ідентифікувати, і що вміст може оброблятися, зберігатися та відображатися публічно як звичайний відгук про проект.",
|
||||
"back": "Назад",
|
||||
"continue": "Продовжити",
|
||||
"submit": "Надіслати",
|
||||
"submitWaiting": "Надіслати ({secondsLeft})",
|
||||
"cancel": "Скасувати",
|
||||
"success": "Дякуємо. Відгук надіслано.",
|
||||
"fakeSuccess": "Дякуємо. Відгук отримано.",
|
||||
"disabled": "Функцію відгуку не ввімкнено.",
|
||||
"error": "Не вдалося надіслати відгук. Повторіть спробу пізніше.",
|
||||
"tooFast": "Зачекайте трохи, перш ніж надсилати форму.",
|
||||
"urlWarning": "URL-адреси дозволені, але видаліть конфіденційні посилання або параметри запиту."
|
||||
},
|
||||
"deleteModal": {
|
||||
"title": "Підтвердити видалення",
|
||||
"body": "Ви збираєтеся видалити: {itemName}. Цю дію не можна скасувати.",
|
||||
"waiting": "Видалити ({secondsLeft})",
|
||||
"ready": "Видалити"
|
||||
},
|
||||
"register": {
|
||||
"intro": "Заповніть основні поля. За потреби можна додати додаткові деталі.",
|
||||
"workSession": "Робоча сесія",
|
||||
"workplace": "Робоче місце",
|
||||
"workAbility": "Працездатність прямо зараз",
|
||||
"energyLevel": "Енергетичний рівень",
|
||||
"mentalClarity": "Ясність розуму / концентрація",
|
||||
"symptomBurden": "Тягар симптомів",
|
||||
"effortStrain": "Зусилля / напруга",
|
||||
"optional": "Додатково",
|
||||
"startTime": "Час початку",
|
||||
"endTime": "Час закінчення",
|
||||
"continueQuestion": "Ви можете продовжити?",
|
||||
"chooseBestNow": "Виберіть варіант, який найкраще підходить прямо зараз.",
|
||||
"optionalDetails": "Додаткові деталі",
|
||||
"physicalEnergy": "Фізична енергія",
|
||||
"mentalEnergy": "Розумова енергія",
|
||||
"productivity": "Відчутна продуктивність",
|
||||
"taskCompletion": "Скільки ви зробили?",
|
||||
"taskTypes": "Робота включала",
|
||||
"limitations": "Що вас найбільше обмежувало?",
|
||||
"accommodations": "Що допомогло?",
|
||||
"note": "Короткий коментар",
|
||||
"missing": "Відсутній: {fields}.",
|
||||
"saved": "Звіт збережено локально.",
|
||||
"saveReport": "Зберегти звіт",
|
||||
"oneEqualsFiveEquals": "1 = {low}, 5 = {high}."
|
||||
},
|
||||
"followup": {
|
||||
"intro": "Використовуйте це наступного дня або пізніше, щоб зафіксувати відстрочені реакції.",
|
||||
"worseQuestion": "Вам гірше, ніж до робочого сеансу?",
|
||||
"chooseOne": "Виберіть одну відповідь.",
|
||||
"connectReport": "Підключитися до звіту",
|
||||
"delayedSymptoms": "Відстрочені симптоми",
|
||||
"recovery": "Відновлення",
|
||||
"missingWorse": "Оберіть, чи гірше вам, ніж до робочого сеансу.",
|
||||
"saved": "Подальший запис збережено локально.",
|
||||
"saveFollowup": "Зберегти подальший запис",
|
||||
"orphan": "Подальший запис без вибраного основного звіту",
|
||||
"worsePrefix": "гірше",
|
||||
"latestOnly": "Подальший запис можна створити лише для останнього звіту."
|
||||
},
|
||||
"edit": {
|
||||
"edit": "Редагувати",
|
||||
"reportIntro": "Відредагуйте звіт. Редагування доступне лише протягом 24 годин після створення.",
|
||||
"followupIntro": "Відредагуйте подальший запис. Редагування доступне лише протягом 24 годин після створення.",
|
||||
"saveChanges": "Зберегти зміни",
|
||||
"saved": "Зміни збережено локально.",
|
||||
"cancelEdit": "Скасувати редагування",
|
||||
"expired": "Редагування доступне лише протягом 24 годин після створення."
|
||||
},
|
||||
"overview": {
|
||||
"noReports": "Жодного звіту ще не збережено.",
|
||||
"report": "Звіт",
|
||||
"followup": "Подальший запис",
|
||||
"closeReport": "Закрити звіт",
|
||||
"trend": "Тренд",
|
||||
"explainTrend": "Поясніть графік трендів",
|
||||
"trendTooltip": "На діаграмі використовуються нормалізовані значення 0-100. Більше значення означає кращу роботу або менше навантаження.",
|
||||
"minimumTwo": "Для діаграми необхідно принаймні два звіти за вибраний період.",
|
||||
"average": "Нормована середня за період",
|
||||
"chartLabel": "Діаграма трендів у часі для звітів",
|
||||
"dashedLine": "Пунктирна лінія означає, що між звітами проходить 3 дні або більше.",
|
||||
"from": "Від",
|
||||
"to": "До",
|
||||
"fromDate": "Від дати",
|
||||
"toDate": "До дати",
|
||||
"datePlaceholder": "дд/мм/рррр",
|
||||
"all": "Усе",
|
||||
"week": "1 тиждень",
|
||||
"month": "1 місяць",
|
||||
"threeMonths": "3 місяці",
|
||||
"sixMonths": "6 місяців",
|
||||
"year": "1 рік",
|
||||
"threeYears": "3 роки",
|
||||
"fiveYears": "5 років"
|
||||
},
|
||||
"privacy": {
|
||||
"warning": "Звіти можуть містити конфіденційну інформацію про здоров'я. Arbeidspuls зберігає дані локально за замовчуванням із додатковим зашифрованим одноразовим доступом, коли ви вибираєте Надіслати електронною поштою.",
|
||||
"demoWarning": "У демонстраційному режимі використовується окреме локальне сховище. Ці звіти є тестовими даними і не змішуються з реальними звітами.",
|
||||
"exportJson": "Експорт JSON",
|
||||
"exportEmail": "Надіслати електронною поштою",
|
||||
"exportRangeTitle": "Експорт даних",
|
||||
"exportRangeIntro": "Додатково виберіть діапазон дат для експорту. Якщо дати не вибрано, експортуються всі локальні дані.",
|
||||
"fromDate": "Від дати",
|
||||
"toDate": "До дати",
|
||||
"allDataDefault": "За замовчуванням: усі дані експортуються.",
|
||||
"exportInvalidRange": "Початкова дата не може бути пізнішою за кінцеву.",
|
||||
"exportSensitiveNotice": "Файл експорту може містити конфіденційну інформацію про здоров'я та функції. Діліться ним лише з людьми, які мають мати доступ.",
|
||||
"emailExportTitle": "Надіслати дані електронною поштою",
|
||||
"recipientEmail": "Електронна адреса одержувача",
|
||||
"recipientPlaceholder": "advisor@example.com",
|
||||
"openEmail": "Відкрийте електронну пошту",
|
||||
"invalidEmail": "Введіть дійсну адресу електронної пошти.",
|
||||
"emailSubject": "Дані звіту Arbeidspuls для перегляду",
|
||||
"emailShared": "Захищений ресурс створено.",
|
||||
"manualAttachmentRequired": "Скопіюйте текст вручну, якщо поштовий клієнт відкрився неправильно.",
|
||||
"limitPeriod": "Обмеження діапазону дат",
|
||||
"allDataSelected": "Усі дані вибрано за замовчуванням.",
|
||||
"emailClientNotice": "Для автоматичного відкриття потрібно налаштувати поштовий клієнт на вашому пристрої. Якщо це не спрацює, ви отримаєте текст, який можна скопіювати та вставити вручну.",
|
||||
"serverStorageConsent": "Я розумію, що вибрані дані тимчасово зберігаються в зашифрованому та нечитабельному вигляді на сервері Arbeidspuls для створення одноразового посилання. Дані видаляються після першого успішного імпорту або через 7 днів.",
|
||||
"serverStorageConsentRequired": "Перш ніж створити зв'язок, потрібно схвалити тимчасове зашифроване сховище на сервері.",
|
||||
"createEmail": "Створити електронну пошту",
|
||||
"emailLinkCreated": "Поштовий клієнт було відкрито, якщо він доступний. Використовуйте скопіювати та вставити текст, якщо він був заповнений неправильно.",
|
||||
"emailLinkFailed": "Не вдалося створити безпечний спільний доступ.",
|
||||
"copyPasteBackupTitle": "Текст електронної пошти вручну",
|
||||
"copyPasteBackupIntro": "Якщо поштовий клієнт неправильно відкрив або заповнив текст, скопіюйте одержувача, тему та текст вручну.",
|
||||
"emailSubjectLabel": "Тема",
|
||||
"emailBodyLabel": "Текст електронної пошти",
|
||||
"secureEmailBody": "Привіт\n\nЯ надаю вам дані Arbeidspuls для перегляду.\n\nВідкрийте це одноразове посилання, щоб отримати та імпортувати дані в поданні консультанта:\n{shareUrl}\n\nПосиланням можна скористатися один раз і термін його дії автоматично закінчується через 7 днів.\n\nЯк діяти далі:\n1. Відкрийте посилання.\n2. Натисніть «Отримати й імпортувати».\n3. Переглядайте графіки, звіти та подальші дії в поданні радника.\n4. За потреби ви можете додати власні нотатки.\n\nДані зберігаються локально у вашому браузері/пристрої після імпорту. Сервер Arbeidspuls зберігає лише тимчасову зашифровану та нечитабельну копію, доки дані не будуть отримані або протягом максимум 7 днів.\n\nArbeidspuls\n{appUrl}",
|
||||
"importBackup": "Імпорт резервної копії",
|
||||
"deleteAll": "Видалити всі локальні дані",
|
||||
"importHelp": "Імпортуйте раніше експортований JSON, якщо ви змінили пристрій або втратили локальні дані. Імпортовані дані зберігаються локально в цьому браузері.",
|
||||
"importFrom": "Імпорт даних із {fileName}",
|
||||
"importStats": "Нові звіти: {reports}. Нові подальші дії: {followups}. Дублікати пропущено: {duplicates}. Конфлікти: {conflicts}.",
|
||||
"demoImportWarning": "Імпорт містить записи демонстраційних даних {count} і може бути імпортований лише в демонстраційному режимі.",
|
||||
"cancelImport": "Скасувати імпорт",
|
||||
"importData": "Імпорт даних",
|
||||
"baseCopy": "Додаток не збирає імена, національні ідентифікаційні номери чи контактну інформацію. За замовчуванням дані зберігаються локально. У разі надсилання електронною поштою вибраний експорт шифрується у браузері перед тимчасовим завантаженням. Вільний текст необов'язковий і має використовуватися лише за потреби.",
|
||||
"storageTitle": "Де зберігаються дані?",
|
||||
"storageBody": "Звіти зберігаються локально в браузері пристрою, який ви використовуєте, у localStorage. Якщо вибрати «Надіслати електронною поштою», на сервер Arbeidspuls тимчасово завантажуватимуться лише зашифровані та нечитабельні дані спільного доступу.",
|
||||
"backupTitle": "Резервне копіювання та зміни пристрою",
|
||||
"backupBody": "Оскільки дані зберігаються локально, вони можуть бути втрачені, якщо ви зміните пристрій, видалите дані браузера, скористаєтеся іншим браузером або скинете пристрій. Регулярно експортуйте файл JSON, якщо хочете зберегти звіти.",
|
||||
"sharingTitle": "Поширення для консультанта",
|
||||
"sharingBody": "Якщо консультант або інша особа переглядатиме звіти, ви можете експортувати локальний файл JSON або використати Надіслати електронною поштою. Обмін електронною поштою створює зашифроване одноразове посилання, де ключ знаходиться у фрагменті URL-адреси та ніколи не надсилається на сервер.",
|
||||
"sensitiveTitle": "Конфіденційна інформація",
|
||||
"sensitiveBody": "Звіти можуть містити інформацію про здоров'я та функції. Діліться файлами JSON або одноразовими посиланнями лише з людьми, які мають мати доступ. Зашифровані спільні ресурси видаляються після першого успішного імпорту або автоматично через 7 днів.",
|
||||
"exportIntegrity": "Експорт включає локальні перевірки цілісності, де звіти містять дані перевірки.",
|
||||
"deleteAllName": "усі локальні звіти"
|
||||
},
|
||||
"share": {
|
||||
"title": "Поширення Arbeidspuls",
|
||||
"intro": "Ви відкрили одноразове поширення даних Arbeidspuls.",
|
||||
"activeImportNotice": "Дані не буде отримано, доки ви не натиснете кнопку. Вони розшифровуються та імпортуються локально в перегляді консультанта.",
|
||||
"fetchAndImport": "Отримання та імпорт",
|
||||
"fetching": "Отримання...",
|
||||
"openAdvisor": "Відкрити перегляд консультанта",
|
||||
"alreadyDeleted": "Цей спільний доступ уже отримано або видалено.",
|
||||
"expired": "Це поширення автоматично видалено, оскільки минуло понад 7 днів.",
|
||||
"requestNewShare": "Попросіть відправника створити новий спільний доступ, якщо вам все ще потрібен доступ.",
|
||||
"missingKey": "У посиланні відсутній ключ розшифровки. Попросіть відправника ще раз надіслати повне посилання.",
|
||||
"missingConfirmToken": "У посиланні відсутній код підтвердження. Попросіть відправника ще раз надіслати повне посилання.",
|
||||
"decryptFailed": "Не вдалося розшифрувати спільний доступ. Посилання може бути неповним або неправильним.",
|
||||
"notFound": "Поширення не існує.",
|
||||
"imported": "Імпорт завершено. Нові записи: {reports}. Дублікати пропущено: {skipped}."
|
||||
},
|
||||
"evaluator": {
|
||||
"eyebrow": "Консультант",
|
||||
"title": "Перегляд консультанта",
|
||||
"backToApp": "Назад до програми",
|
||||
"importJson": "Імпорт JSON",
|
||||
"importHelp": "Виберіть експорт із програми. Дані зберігаються локально в цьому браузері.",
|
||||
"displayName": "Ім'я/ідентифікатор",
|
||||
"optionalImportName": "Додатково під час імпорту",
|
||||
"connectExisting": "Підключитися до існуючої людини",
|
||||
"autoMatch": "Створіть новий або скористайтеся автоматичним збігом",
|
||||
"chooseJson": "Виберіть файл JSON",
|
||||
"exportBackup": "Експорт даних консультанта",
|
||||
"importBackup": "Імпорт резервної копії консультанта",
|
||||
"importedBackup": "Резервну копію консультанта імпортовано.",
|
||||
"demoDataWarning": "Цей імпорт містить демонстраційні дані. Не використовуйте його як справжню інформацію про користувача чи здоров'я.",
|
||||
"importStart": "Щоб почати перегляд, імпортуйте файл JSON.",
|
||||
"saveName": "Зберегти назву",
|
||||
"deletePerson": "Видалити користувача та звіти",
|
||||
"deletePersonTitle": "Після підтвердження буде видалено користувача, імпортовані звіти, подальші записи та нотатки консультанта.",
|
||||
"reportCount": "Звітів: {count}",
|
||||
"personNote": "Загальна примітка для користувача",
|
||||
"personNoteHelp": "Ця примітка стосується особи в цілому, а не окремого звіту.",
|
||||
"saveNote": "Зберегти нотатку",
|
||||
"changedAt": "Останні зміни: {time}",
|
||||
"searchReports": "Пошук у звітах",
|
||||
"newest": "Спочатку найновіші",
|
||||
"oldest": "Спочатку найстаріші",
|
||||
"noSelectedTitle": "Звіт не вибрано",
|
||||
"noSelectedBody": "Виберіть звіт зі списку, щоб переглянути деталі, подальші дії та примітки.",
|
||||
"details": "Показати деталі",
|
||||
"reportDetails": "Деталі звіту",
|
||||
"followupDetails": "Деталі подальшого запису",
|
||||
"closeDetails": "Закрийте деталі",
|
||||
"reportNote": "Нотатка до звіту",
|
||||
"followupNote": "Нотатка до подальшого запису",
|
||||
"noteHelp": "Примітка консультанта зберігається локально та не змінює дані імпортованого звіту.",
|
||||
"deleteNote": "Видалити примітку",
|
||||
"validationError": "Помилка підтвердження",
|
||||
"comment": "коментар",
|
||||
"evaluatorNote": "Нотатка консультанта",
|
||||
"followupNoteBadge": "Нотатка до подальшого запису",
|
||||
"hasFollowup": "Є подальший запис",
|
||||
"hasComment": "Є коментарі користувача",
|
||||
"hasEvaluatorNote": "Є нотатка консультанта",
|
||||
"scoreValues": "Значення оцінок",
|
||||
"importReadError": "Не вдалося прочитати файл імпорту.",
|
||||
"importMatched": "Імпорт пов'язано з наявним користувачем.",
|
||||
"importCreated": "Створено нового користувача.",
|
||||
"importFailed": "Не вдалося виконати імпорт.",
|
||||
"backupFailed": "Не вдалося імпортувати резервну копію.",
|
||||
"demoBackupWarning": "ПОПЕРЕДЖЕННЯ: резервна копія містить демонстраційні записи: {count}.",
|
||||
"importResult": "{action} Нових звітів: {added}, пропущено дублікатів: {skipped}.",
|
||||
"demoImportResultWarning": "ПОПЕРЕДЖЕННЯ: імпорт містить демонстраційні записи: {count}.",
|
||||
"verificationWarning": "Деякі звіти не вдалося перевірити."
|
||||
},
|
||||
"details": {
|
||||
"summary": "Короткий зміст",
|
||||
"coreValues": "Основні цінності",
|
||||
"details": "Подробиці",
|
||||
"userComment": "Коментар користувача",
|
||||
"followups": "Подальші записи",
|
||||
"technical": "Технічна інформація",
|
||||
"rawData": "Необроблені дані",
|
||||
"integrity": "Перевірка цілісності",
|
||||
"dataMode": "Режим даних",
|
||||
"time": "Час",
|
||||
"workTime": "Час роботи",
|
||||
"status": "Статус",
|
||||
"totalScore": "Загальний бал",
|
||||
"functionLevel": "Рівень функції",
|
||||
"description": "Опис",
|
||||
"important": "Важливо",
|
||||
"workAbility": "Працездатність",
|
||||
"energy": "Енергетичний рівень",
|
||||
"physicalEnergy": "Фізична енергія",
|
||||
"mentalEnergy": "Розумова енергія",
|
||||
"mentalClarity": "Розумова ясність",
|
||||
"symptoms": "Тягар симптомів",
|
||||
"strain": "Зусилля та навантаження",
|
||||
"productivity": "Продуктивність",
|
||||
"taskCompletion": "Скільки ви зробили?",
|
||||
"taskTypes": "Робота включала",
|
||||
"limitations": "Обмежується",
|
||||
"accommodations": "Що допомогло",
|
||||
"comment": "Коментар",
|
||||
"noComment": "Без коментарів",
|
||||
"noOptionalDetails": "Додаткові деталі не надано.",
|
||||
"technicalDescription": "Розширена інформація для контролю, усунення несправностей і перевірки.",
|
||||
"rawDescription": "Повний перегляд JSON для усунення несправностей або технічного контролю.",
|
||||
"copyRaw": "Скопіюйте необроблені дані",
|
||||
"reportId": "Ідентифікатор звіту",
|
||||
"followupId": "ID подальшого запису",
|
||||
"connectedReport": "Підключений звіт",
|
||||
"created": "Створено",
|
||||
"updated": "Оновлено",
|
||||
"reportDate": "Дата звіту",
|
||||
"reportTime": "Час звіту",
|
||||
"integrityStatus": "Статус цілісності",
|
||||
"explanation": "Пояснення",
|
||||
"algorithm": "Алгоритм",
|
||||
"signed": "Підписано",
|
||||
"payloadHash": "Хеш корисного навантаження",
|
||||
"type": "Тип",
|
||||
"canonicalization": "Канонізація",
|
||||
"scoreUnavailable": "Загальна оцінка відсутня або її не вдалося розрахувати.",
|
||||
"worse": "Гірше ніж раніше",
|
||||
"delayedSymptoms": "Відстрочені симптоми",
|
||||
"recovery": "Відновлення",
|
||||
"outOfFive": "{value} з 5",
|
||||
"worseValue": "Гірше: {value}",
|
||||
"recoveryValue": "Відновлення: {value}"
|
||||
},
|
||||
"danger": {
|
||||
"title": "Небезпечна зона",
|
||||
"deleteEntryHelp": "Це видаляє звіт або подальші дії з локального сховища в цьому браузері.",
|
||||
"deleteReport": "Видалити звіт",
|
||||
"deleteFollowup": "Видалити подальший запис",
|
||||
"reportName": "звіт",
|
||||
"followupName": "подальший запис"
|
||||
},
|
||||
"graphModal": {
|
||||
"open": "Відкрити діаграму на весь екран",
|
||||
"title": "Аналіз діаграми",
|
||||
"close": "Закрити діаграму",
|
||||
"timeRange": "Діапазон часу",
|
||||
"visibleLines": "Видимі лінії",
|
||||
"visibleHelp": "Натисніть один раз для частково видимого, двічі для прихованого.",
|
||||
"snapHelp": "Наведіть курсор на діаграму, щоб побачити точні значення звіту.",
|
||||
"noReports": "Немає звітів за вибраний період.",
|
||||
"noPoint": "Точка звіту не вибрана",
|
||||
"noPointHelp": "Наведіть курсор біля точки, щоб побачити фактичні значення звіту.",
|
||||
"nearestReport": "Найближчий звіт",
|
||||
"chartLabel": "Повноекранна діаграма тенденцій для звітів",
|
||||
"workTime": "Час роботи",
|
||||
"workTimeLegend": "Затінені області показують зареєстрований робочий час",
|
||||
"workTimeTooltip": "Оформлений робочий час",
|
||||
"followupResponse": "Реакція після роботи",
|
||||
"followupResponseHelp": "Показує наступні дії в їх фактичний час і візуалізує наслідки робочого сеансу.",
|
||||
"followupResponseDisclaimer": "Це візуальний індикатор наслідків, а не нова загальна оцінка.",
|
||||
"noFollowups": "Немає подальших дій для вибраного звіту.",
|
||||
"followupDelay": "Затримка: {hours} годин",
|
||||
"followupEffect": "Післядія: {score}% зі звіту {parent}%",
|
||||
"metricTotalScore": "Загальна оцінка",
|
||||
"metricWorkAbility": "Працездатність",
|
||||
"metricEnergy": "Енергія",
|
||||
"metricClarity": "Ясність мислення",
|
||||
"metricSymptoms": "Симптоми",
|
||||
"metricStrain": "Навантаження",
|
||||
"metricTotalScoreShort": "З",
|
||||
"metricWorkAbilityShort": "П",
|
||||
"metricEnergyShort": "Е",
|
||||
"metricClarityShort": "Я",
|
||||
"metricSymptomsShort": "С",
|
||||
"metricStrainShort": "Н"
|
||||
},
|
||||
"scoring": {
|
||||
"disclaimer": "Загальна оцінка нормалізується таким чином, що 75% приблизно відповідає очікуваному/хорошому функціональному рівню, тоді як 100% відповідає виключно високому функціональному рівню. Оцінка є внутрішнім індикатором функції для огляду та тенденції, а не медичного висновку.",
|
||||
"periodLabel": "Оцінка працездатності за період",
|
||||
"periodEmpty": "Немає даних за вибраний період",
|
||||
"periodTooltip": "Це нормалізована оцінка на основі основних звітів у вибраному часовому діапазоні. 75% приблизно вважається очікуваним/хорошим рівнем функціонування. Оцінка підтверджує огляд і тенденцію, а не медичний висновок.",
|
||||
"exceptionallyHigh": "Винятково високий рівень функціональності",
|
||||
"expected": "Очікуваний функціональний рівень",
|
||||
"moderatelyReduced": "Помірно знижена функція",
|
||||
"significantlyReduced": "Значно знижена функція",
|
||||
"veryLow": "Дуже низький рівень функціонування",
|
||||
"exceptionallyHighShort": "Винятково висока",
|
||||
"expectedShort": "Очікуваний",
|
||||
"moderatelyReducedShort": "Помірно знижений",
|
||||
"significantlyReducedShort": "Значно зменшено",
|
||||
"veryLowShort": "Дуже низький"
|
||||
},
|
||||
"verification": {
|
||||
"valid": "Цілісність в порядку",
|
||||
"modified": "Можливо змінено",
|
||||
"missing": "Відсутній локальний хеш",
|
||||
"unsupported": "Перевірка не підтримується",
|
||||
"error": "Помилка перевірки",
|
||||
"invalidLabel": "Попередження: звіт не вдалося перевірити"
|
||||
}
|
||||
};
|
||||
134
src/labels.uk.ts
Normal file
134
src/labels.uk.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import type {
|
||||
Accommodation,
|
||||
DelayedSymptom,
|
||||
Limitation,
|
||||
RecoveryStatus,
|
||||
ReportStatus,
|
||||
ScaleValue,
|
||||
TaskCompletion,
|
||||
TaskType,
|
||||
WorseThanBefore
|
||||
} from "./types";
|
||||
|
||||
export const ukStatusLabels = {
|
||||
kan_fortsette: "Може продовжувати",
|
||||
trenger_pause: "Потрібна перерва",
|
||||
trenger_enklere_oppgave: "Потрібне простіше завдання",
|
||||
bør_stoppe: "Варто зупинитися",
|
||||
onsker_a_stoppe: "Хоче зупинитися",
|
||||
ønsker_å_stoppe: "Хоче зупинитися"
|
||||
} satisfies Record<ReportStatus, string>;
|
||||
|
||||
export const ukTaskTypeLabels = {
|
||||
sitting: "Сидяча робота",
|
||||
standing: "Робота стоячи",
|
||||
walking: "Ходьба",
|
||||
lifting: "Підіймання",
|
||||
computer_work: "Робота за комп'ютером",
|
||||
concentration: "Концентрація",
|
||||
multitasking: "Багатозадачність",
|
||||
talking_social: "Спілкування",
|
||||
noise: "Шум",
|
||||
bright_light: "Яскраве світло",
|
||||
time_pressure: "Брак часу"
|
||||
} satisfies Record<TaskType, string>;
|
||||
|
||||
export const ukLimitationLabels = {
|
||||
fatigue: "Втома",
|
||||
brain_fog: "Затьмарення свідомості",
|
||||
pain: "Біль",
|
||||
headache_migraine: "Головний біль або мігрень",
|
||||
dizziness: "Запаморочення",
|
||||
nausea: "Нудота",
|
||||
sensory_overload: "Сенсорне перевантаження",
|
||||
stress_anxiety: "Стрес або тривога",
|
||||
weakness: "Слабкість",
|
||||
sleepiness: "Сонливість",
|
||||
other: "Інше"
|
||||
} satisfies Record<Limitation, string>;
|
||||
|
||||
export const ukAccommodationLabels = {
|
||||
extra_breaks: "Додаткові перерви",
|
||||
sitting_down: "Можливість сісти",
|
||||
quiet_room: "Тихе приміщення",
|
||||
reduced_pace: "Повільніший темп",
|
||||
shorter_task: "Коротше завдання",
|
||||
help_from_others: "Допомога інших",
|
||||
remote_work: "Віддалена робота",
|
||||
task_switching: "Зміна завдань",
|
||||
nothing_helped: "Нічого не допомогло"
|
||||
} satisfies Record<Accommodation, string>;
|
||||
|
||||
export const ukTaskCompletionLabels = {
|
||||
nesten_ingenting: "Майже нічого",
|
||||
litt: "Трохи",
|
||||
delvis: "Частково",
|
||||
som_forventet: "Як очікувалося",
|
||||
mer_enn_forventet: "Більше, ніж очікувалося"
|
||||
} satisfies Record<TaskCompletion, string>;
|
||||
|
||||
export const ukWorseLabels = {
|
||||
nei: "Ні",
|
||||
litt: "Трохи",
|
||||
moderat: "Помірно",
|
||||
mye: "Значно"
|
||||
} satisfies Record<WorseThanBefore, string>;
|
||||
|
||||
export const ukDelayedSymptomLabels = {
|
||||
fatigue: "Втома",
|
||||
pain: "Біль",
|
||||
brain_fog: "Затьмарення свідомості",
|
||||
flu_like: "Грипоподібний стан",
|
||||
headache: "Головний біль",
|
||||
dizziness: "Запаморочення",
|
||||
sleep_disruption: "Порушення сну",
|
||||
sensory_sensitivity: "Чутливість до світла або звуку"
|
||||
} satisfies Record<DelayedSymptom, string>;
|
||||
|
||||
export const ukRecoveryLabels = {
|
||||
tilbake_til_baseline: "Повернення до звичного рівня",
|
||||
samme_dag: "Того самого дня",
|
||||
en_dag: "Один день",
|
||||
to_tre_dager: "Два-три дні",
|
||||
fire_pluss_dager: "Чотири дні або більше",
|
||||
ikke_tilbake_til_baseline: "Ще не відновився звичний рівень"
|
||||
} satisfies Record<RecoveryStatus, string>;
|
||||
|
||||
export const ukPositiveScale = {
|
||||
1: "Дуже низький",
|
||||
2: "Низький",
|
||||
3: "Середній",
|
||||
4: "Добрий",
|
||||
5: "Дуже добрий"
|
||||
} satisfies Record<ScaleValue, string>;
|
||||
|
||||
export const ukNegativeScale = {
|
||||
1: "Майже немає",
|
||||
2: "Незначне",
|
||||
3: "Помірне",
|
||||
4: "Високе",
|
||||
5: "Дуже високе"
|
||||
} satisfies Record<ScaleValue, string>;
|
||||
|
||||
export const ukTooltipTexts = {
|
||||
work_ability: "Наскільки добре ви можете функціонувати в робочій ситуації зараз, незалежно від того, чи вдається виконати все.",
|
||||
energy_level: "Загальний відчутний рівень енергії. Якщо ви не розрізняєте фізичну й розумову енергію, це значення використовується для обох.",
|
||||
physical_energy: "Фізична енергія, витривалість і фізичні можливості.",
|
||||
mental_energy: "Розумова витривалість, здатність довго зберігати увагу й витримувати когнітивне навантаження.",
|
||||
mental_clarity: "Наскільки ясно ви мислите та наскільки легко зосереджуватися, пам'ятати, розуміти й стежити за перебігом роботи.",
|
||||
symptom_burden: "Наскільки симптоми турбують або обмежують вас, наприклад біль, головний біль, запаморочення, нудота чи сенсорне перевантаження.",
|
||||
effort_strain: "Скільки зусиль потребує виконання роботи. Йдеться про навантаження, а не лише про інтенсивність симптомів.",
|
||||
symptom_vs_effort: "Тягар симптомів описує їхню вираженість. Зусилля та навантаження описують, наскільки сильно доводиться напружуватися для виконання роботи.",
|
||||
total_score_percent: "Розрахований показник функціонування від 0 до 100 на основі відповідей. Це не медично валідований тест.",
|
||||
workplace: "Місце або контекст роботи. За наявності поле заповнюється значенням із попереднього звіту.",
|
||||
work_time: "Час початку й завершення робочого сеансу. Значення можна змінити до збереження звіту."
|
||||
};
|
||||
|
||||
export const ukCategoryTooltipTexts = {
|
||||
A: "Працездатність",
|
||||
E: "Енергія",
|
||||
K: "Ясність мислення та концентрація",
|
||||
S: "Тягар симптомів",
|
||||
B: "Зусилля та навантаження",
|
||||
T: "Загальна оцінка"
|
||||
};
|
||||
@ -860,6 +860,12 @@ textarea {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tooltip [role="tooltip"] {
|
||||
width: min(200px, calc(100vw - 32px));
|
||||
}
|
||||
}
|
||||
|
||||
.compact-session,
|
||||
.period-panel {
|
||||
border: 1px solid #d9ded8;
|
||||
@ -1881,6 +1887,7 @@ textarea {
|
||||
}
|
||||
|
||||
.footer-language-switcher {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user