Stand 16.10.2026. Enthält das Marketing-Dashboard, die Daten in daten/, die Produktionsvorgaben, Reels und das Webinar mit Skript, Sprechzettel und Präsentation (HTML, PowerPoint, PDF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
8.0 KiB
JavaScript
160 lines
8.0 KiB
JavaScript
// Kleine Helfer für Datum, Text, Markdown und Kanäle.
|
|
// Alles mit Datum rechnet mit reinen Kalendertagen in UTC, damit die
|
|
// Sommerzeit keinen Tag verschiebt.
|
|
|
|
export const WT = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
|
export const WT_LANG = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
|
|
export const MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
|
export const MON_KURZ = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
|
|
|
export const tag = (iso) => new Date(iso + "T00:00:00Z");
|
|
export const isoVon = (d) => d.toISOString().slice(0, 10);
|
|
export const plus = (iso, n) => { const d = tag(iso); d.setUTCDate(d.getUTCDate() + n); return isoVon(d); };
|
|
export const ttmm = (iso) => `${iso.slice(8, 10)}.${iso.slice(5, 7)}.`;
|
|
export const wt = (iso) => WT[tag(iso).getUTCDay()];
|
|
export const kurzDatum = (iso) => `${wt(iso)} ${ttmm(iso)}`;
|
|
export const langDatum = (iso) => {
|
|
const d = tag(iso);
|
|
return `${WT_LANG[d.getUTCDay()]}, ${d.getUTCDate()}. ${MONATE[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
};
|
|
export const tageZwischen = (a, b) => Math.round((tag(b) - tag(a)) / 86400000);
|
|
export const montag = (iso) => plus(iso, -((tag(iso).getUTCDay() + 6) % 7));
|
|
|
|
export function kw(iso) {
|
|
const d = tag(iso);
|
|
d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7) + 3);
|
|
const erster = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
|
return 1 + Math.round(((d - erster) / 86400000 - 3 + ((erster.getUTCDay() + 6) % 7)) / 7);
|
|
}
|
|
|
|
// "12. bis 18.10." im selben Monat, sonst "26.10. bis 01.11."
|
|
export function spanne(von, bis) {
|
|
if (von.slice(0, 7) === bis.slice(0, 7)) return `${von.slice(8, 10)}. bis ${ttmm(bis)}`;
|
|
return `${ttmm(von)} bis ${ttmm(bis)}`;
|
|
}
|
|
|
|
// Heute, oder zum Ausprobieren ein festes Datum über ?heute=2026-10-21.
|
|
export function heute() {
|
|
const q = new URLSearchParams(location.search).get("heute");
|
|
if (q && /^\d{4}-\d{2}-\d{2}$/.test(q)) return q;
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function abstand(von, bis) {
|
|
const n = tageZwischen(von, bis);
|
|
if (n === 0) return "heute";
|
|
if (n === 1) return "morgen";
|
|
if (n === -1) return "gestern";
|
|
return n > 0 ? `in ${n} Tagen` : `vor ${-n} Tagen`;
|
|
}
|
|
|
|
// Zeitstempel vom Server (Sekunden) als "05.10., 20:41"
|
|
export function zeitstempel(sek) {
|
|
if (!sek) return "";
|
|
const d = new Date(sek * 1000);
|
|
const zz = (n) => String(n).padStart(2, "0");
|
|
return `${zz(d.getDate())}.${zz(d.getMonth() + 1)}., ${zz(d.getHours())}:${zz(d.getMinutes())}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Text
|
|
|
|
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
export const mdInline = (s) => window.marked.parseInline(String(s ?? ""));
|
|
export const md = (s) => window.marked.parse(String(s ?? ""), { gfm: true, breaks: true });
|
|
export const ohneMd = (s) => String(s ?? "").replace(/\*\*|`/g, "");
|
|
|
|
export function slug(text) {
|
|
return String(text).toLowerCase()
|
|
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "abschnitt";
|
|
}
|
|
|
|
export const dokLink = (pfad, extra = "") =>
|
|
`#/dok/${pfad.split("/").map(encodeURIComponent).join("/")}${extra}`;
|
|
|
|
// ---------------------------------------------------------------- Formate und Kanäle
|
|
|
|
const FORMAT_CODE = /\b(F\d{1,2}b?|CH|L[1-4])\b/g;
|
|
export const codes = (s) => [...new Set(String(s ?? "").match(FORMAT_CODE) || [])];
|
|
|
|
export const KANAL_KURZ = {
|
|
"Instagram": "IG", "Facebook": "FB", "TikTok": "TT", "YouTube Shorts": "YT",
|
|
"LinkedIn": "LI", "Instagram Stories": "ST", "intern": "IN",
|
|
};
|
|
export const KANAL_FILTER = [
|
|
["alle", "Alle"], ["Instagram", "Instagram"], ["LinkedIn", "LinkedIn"], ["TikTok", "TikTok"],
|
|
["Facebook", "Facebook"], ["YouTube Shorts", "YouTube Shorts"], ["Instagram Stories", "Stories"], ["intern", "intern"],
|
|
];
|
|
|
|
// Kanäle als graue Kürzel, der volle Name steht im Tooltip.
|
|
export function kanalBadges(kanaele) {
|
|
return `<span class="kn" title="${esc(kanaele.join(", "))}">${kanaele.map((k) => KANAL_KURZ[k] || esc(k)).join(" ")}</span>`;
|
|
}
|
|
|
|
// Welche Art Beitrag ein Slot ist. Danach richtet sich die Farbe im Kalender.
|
|
export function art(slot) {
|
|
const k = slot.kanaele;
|
|
if (k.includes("intern")) return "intern";
|
|
if (k.includes("Instagram Stories")) return "story";
|
|
if (k.includes("LinkedIn")) return "linkedin";
|
|
if (k.includes("TikTok") || k.includes("YouTube Shorts")) return "reel";
|
|
return "carousel";
|
|
}
|
|
export const ART_NAME = { carousel: "Carousel", reel: "Reel", linkedin: "LinkedIn", story: "Story", intern: "intern" };
|
|
|
|
// Funnel-Stufe eines Slots: am Slot gesetzt, sonst die des ersten Formats.
|
|
export const FUNNEL_NAME = { top: "Top", middle: "Middle", bottom: "Bottom" };
|
|
export function funnel(s, plan) {
|
|
if (s.funnel) return s.funnel;
|
|
if (s.kanaele.includes("intern")) return null;
|
|
const code = codes(s.format)[0];
|
|
const f = plan.formate.find((x) => x.kuerzel === code) || plan.linkedin.find((x) => x.kuerzel === code);
|
|
return f?.funnel || null;
|
|
}
|
|
export function funnelMix(slots, plan) {
|
|
const z = { top: 0, middle: 0, bottom: 0 };
|
|
for (const s of slots) { const f = funnel(s, plan); if (f) z[f] += 1; }
|
|
const n = z.top + z.middle + z.bottom;
|
|
const pz = (k) => (n ? Math.round((z[k] / n) * 100) : 0);
|
|
return { n, top: pz("top"), middle: pz("middle"), bottom: pz("bottom") };
|
|
}
|
|
// Ein Balken mit den drei Anteilen und der Zielspanne für Top.
|
|
export function funnelBalken(m, ziel) {
|
|
return `<span class="fbalken" title="Top ${m.top} %, Middle ${m.middle} %, Bottom ${m.bottom} %">
|
|
<span class="fb-top" style="width:${m.top}%"></span><span class="fb-middle" style="width:${m.middle}%"></span><span class="fb-bottom" style="width:${m.bottom}%"></span>
|
|
${ziel ? `<span class="fb-ziel" style="left:${ziel[0]}%;width:${ziel[1] - ziel[0]}%"></span>` : ""}
|
|
</span>`;
|
|
}
|
|
|
|
export const SLOT_STATUS = ["geplant", "fertig", "erschienen", "ausgefallen"];
|
|
export const SCHRITT_STATUS = ["offen", "läuft", "erledigt", "später", "entfällt"];
|
|
|
|
// Status als Text mit einem kleinen Quadrat davor (Farbe und Füllung im Stylesheet).
|
|
export const pille = (status, text) => `<span class="st st-${esc(status)}">${esc(text ?? status)}</span>`;
|
|
|
|
// "Dejan, auf Anfrage von Noah" ist eine Aufgabe von Dejan, nicht von Noah.
|
|
export const zustaendig = (schritt, name) =>
|
|
new RegExp(`\\b${name}\\b`).test((schritt.wer || "").replace(/auf Anfrage von \S+/g, ""));
|
|
|
|
export const istOffen = (s) => s.status === "offen" || s.status === "läuft";
|
|
|
|
// Kleine Liniengrafik ohne Bibliothek: eine Linie, der letzte Wert markiert.
|
|
export function verlaufSvg(werte, breite = 320, hoehe = 70) {
|
|
if (werte.length < 2) return "";
|
|
const xs = werte.map((w) => tag(w.datum).getTime());
|
|
const ys = werte.map((w) => w.wert);
|
|
const [x0, x1] = [Math.min(...xs), Math.max(...xs)];
|
|
const y1 = Math.max(...ys) * 1.1;
|
|
const px = (x) => 3 + ((x - x0) / (x1 - x0 || 1)) * (breite - 6);
|
|
const py = (y) => hoehe - 3 - (y / (y1 || 1)) * (hoehe - 6);
|
|
const punkte = werte.map((w, i) => `${px(xs[i]).toFixed(1)},${py(ys[i]).toFixed(1)}`).join(" ");
|
|
const [lx, ly] = [px(xs.at(-1)), py(ys.at(-1))];
|
|
return `<svg class="verlauf" viewBox="0 0 ${breite} ${hoehe}" role="img" aria-label="Verlauf">
|
|
<line x1="0" y1="${hoehe - 0.5}" x2="${breite}" y2="${hoehe - 0.5}" stroke="#E3E3DE" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
|
<polyline points="${punkte}" fill="none" stroke="#151515" stroke-width="1.5" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
|
|
${werte.map((w, i) => `<circle cx="${px(xs[i]).toFixed(1)}" cy="${py(ys[i]).toFixed(1)}" r="6" fill="transparent"><title>${ttmm(w.datum)}: ${w.wert}</title></circle>`).join("")}
|
|
<rect x="${(lx - 3).toFixed(1)}" y="${(ly - 3).toFixed(1)}" width="6" height="6" fill="#F1B729"/>
|
|
</svg>`;
|
|
}
|