commit 21edee59aa7e978a9bbc3eb52c60edbaf1984b1b
Author: samuelzielke
Date: Thu Oct 22 13:15:14 2026 +0200
chore: initial commit of Mawari 1.0.0
Co-Authored-By: Claude Opus 5.5
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bd4cb17
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# macOS
+.DS_Store
+
+# Xcode / Build
+build/
+DerivedData/
+xcuserdata/
+*.xcuserstate
+*.xcscmblueprint
+*.moved-aside
+build-dmg.log
+*.log
+
+# SwiftPM
+.build/
+.swiftpm/
+
+# Aus project.yml generiert (xcodegen)
+Mawari.xcodeproj/
+App/Info.plist
+
+# Schlüssel und Exporte – gehören NIE ins Repo
+*.key
+*.p12
+*.pem
+mawari-sparkle.key
+
+# Echte Daten (falls mal zum Testen in den Projektordner kopiert)
+daten.json
+leben-und-dienst*.json
+mawari-sicherung-*.json
+Sicherungen/
diff --git a/App/Mawari.entitlements b/App/Mawari.entitlements
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/App/Mawari.entitlements
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/App/Resources/AppIcon.icns b/App/Resources/AppIcon.icns
new file mode 100644
index 0000000..49f874f
Binary files /dev/null and b/App/Resources/AppIcon.icns differ
diff --git a/App/Sources/EntrySheet.swift b/App/Sources/EntrySheet.swift
new file mode 100644
index 0000000..c0264da
--- /dev/null
+++ b/App/Sources/EntrySheet.swift
@@ -0,0 +1,93 @@
+import SwiftUI
+
+struct EntrySheet: View {
+ @EnvironmentObject var store: Store
+ @EnvironmentObject var ui: UIState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var entry: Entry
+ @State private var date: Date
+ @State private var showAll = false
+
+ init(entry: Entry) {
+ _entry = State(initialValue: entry)
+ _date = State(initialValue: Day.date(entry.date) ?? Date())
+ }
+
+ private var task: DutyTask? { store.task(entry.task) }
+ private var showPartner: Bool { task?.withPartner == true || !entry.partner.isEmpty }
+
+ private func people(for taskID: String?, keep: String) -> [Person] {
+ store.personsSorted.filter { showAll || taskID == nil || $0.may(taskID!) || $0.id == keep }
+ }
+
+ private var warnings: [String] {
+ var w: [String] = []
+ if let t = task, let p = store.person(entry.person), !p.may(t.id) { w.append("\(p.name) ist für „\(t.name)“ nicht freigegeben.") }
+ if let pa = store.person(entry.partner), !pa.may("partner") { w.append("\(pa.name) ist nicht als Partner freigegeben.") }
+ if !entry.partner.isEmpty, entry.partner == entry.person { w.append("Person und Partner sind gleich.") }
+ return w
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(entry.isNew ? "Neuer Eintrag" : "Eintrag bearbeiten").font(.title3.weight(.semibold))
+
+ Form {
+ DatePicker("Datum", selection: $date, displayedComponents: .date)
+ .datePickerStyle(.field)
+ Picker("Aufgabe", selection: $entry.task) {
+ Text("Aufgabe wählen …").tag("")
+ ForEach(TaskGroup.allCases) { g in
+ Section(g.shortTitle) {
+ ForEach(store.db.tasks.filter { $0.group == g && !$0.partnerColumn }) { Text($0.name).tag($0.id) }
+ }
+ }
+ }
+ Picker("Person", selection: $entry.person) {
+ Text("Person wählen …").tag("")
+ ForEach(people(for: task?.id, keep: entry.person)) { Text($0.name).tag($0.id) }
+ }
+ Toggle("Alle Personen anzeigen (auch ohne Freigabe)", isOn: $showAll)
+ if showPartner {
+ Picker("Partner", selection: $entry.partner) {
+ Text("– kein Partner –").tag("")
+ ForEach(people(for: "partner", keep: entry.partner)) { Text($0.name).tag($0.id) }
+ }
+ }
+ TextField("Thema / Notiz", text: $entry.note, prompt: Text("optional"))
+ }
+ .formStyle(.columns)
+
+ ForEach(warnings, id: \.self) { w in
+ Label(w, systemImage: "exclamationmark.triangle").foregroundStyle(Theme.kohaku).font(.callout)
+ }
+
+ HStack {
+ if !entry.isNew {
+ Button("Löschen", role: .destructive) { store.delete(entry: entry.id); dismiss() }
+ }
+ Spacer()
+ Button("Abbrechen", role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction)
+ Button(entry.isNew ? "Eintragen" : "Sichern", action: save)
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .disabled(entry.task.isEmpty || entry.person.isEmpty)
+ }
+ }
+ .padding(22)
+ .frame(width: 460)
+ }
+
+ private func save() {
+ var e = entry
+ e.date = Day.string(date)
+ if !(task?.withPartner ?? false) && e.partner.isEmpty { e.partner = "" }
+ let wasNew = e.isNew
+ if wasNew { e.id = newID("e") }
+ e.note = e.note.trimmingCharacters(in: .whitespaces)
+ store.save(entry: e)
+ dismiss()
+ ui.show(wasNew ? "Eingetragen: \(store.task(e.task)?.name ?? "") · \(store.name(e.person))" : "Eintrag geändert.")
+ }
+}
diff --git a/App/Sources/Exporter.swift b/App/Sources/Exporter.swift
new file mode 100644
index 0000000..7a60131
--- /dev/null
+++ b/App/Sources/Exporter.swift
@@ -0,0 +1,206 @@
+import AppKit
+import UniformTypeIdentifiers
+
+/// Export für Numbers (.xlsx), Sicherung speichern und laden.
+/// Eine echte .numbers-Datei lässt sich nicht erzeugen (Apple-eigenes Format);
+/// die .xlsx öffnet Numbers direkt und wird nach dem Speichern dort geöffnet.
+@MainActor
+enum Exporter {
+
+ static func exportXlsx(store: Store, ui: UIState) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
+ panel.nameFieldStringValue = "Leben-und-Dienst-\(Day.today).xlsx"
+ panel.message = "Der aktuelle Stand wird als Tabelle gespeichert und in Numbers geöffnet."
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ do {
+ try XLSXWriter.workbook(sheets: sheets(store: store)).write(to: url, options: .atomic)
+ openInNumbers(url)
+ ui.show("Export gespeichert.")
+ } catch {
+ ui.show("Export fehlgeschlagen: \(error.localizedDescription)")
+ }
+ }
+
+ static func saveBackup(store: Store, ui: UIState) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.json]
+ panel.nameFieldStringValue = "mawari-sicherung-\(Day.today).json"
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ do { try store.encoded().write(to: url, options: .atomic); ui.show("Sicherung gespeichert.") }
+ catch { ui.show("Sicherung fehlgeschlagen: \(error.localizedDescription)") }
+ }
+
+ static func importBackup(store: Store, ui: UIState) {
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.json]
+ panel.message = "Sicherung oder Datei aus dem HTML-Tool (leben-und-dienst.json) wählen. Der aktuelle Stand wird dabei ersetzt (eine Kopie bleibt im Datenordner)."
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ if !store.db.persons.isEmpty || !store.db.entries.isEmpty {
+ let alert = NSAlert()
+ alert.messageText = "Aktuelle Daten ersetzen?"
+ alert.informativeText = "Alle Personen und Einträge werden durch die Datei ersetzt. Der bisherige Stand wird vorher im Datenordner gesichert."
+ alert.addButton(withTitle: "Ersetzen")
+ alert.addButton(withTitle: "Abbrechen")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ }
+ do {
+ try store.importFile(url)
+ ui.tab = .overview
+ ui.show("Geladen: \(store.db.persons.count) Personen, \(store.db.entries.count) Einträge.")
+ } catch {
+ ui.show("Datei konnte nicht gelesen werden.")
+ }
+ }
+
+ private static func openInNumbers(_ url: URL) {
+ if let numbers = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.iWork.Numbers") {
+ NSWorkspace.shared.open([url], withApplicationAt: numbers, configuration: NSWorkspace.OpenConfiguration())
+ } else {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ // MARK: Inhalt
+
+ private static func sheets(store: Store) -> [XLSXWriter.Sheet] {
+ typealias C = XLSXWriter.Cell
+ var ov: [[C]] = [
+ [.text("Leben und Dienst – Übersicht", .title)],
+ [.text("Stand \(Day.display(Day.today)) · Datum = letzte Einteilung · nie = freigegeben, aber noch nie eingeteilt · – = nicht freigegeben")],
+ [],
+ ]
+ var maxCols = 2
+ for group in TaskGroup.allCases {
+ let d = UserDefaults.standard
+ let sort = OverviewSort(column: d.string(forKey: "sort.\(group.rawValue).column") ?? "name",
+ ascending: d.object(forKey: "sort.\(group.rawValue).ascending") as? Bool ?? true)
+ let (tasks, rows) = overviewRows(store: store, group: group, sort: sort)
+ maxCols = max(maxCols, tasks.count + 2)
+ let band: XLSXWriter.Style = group == .diener ? .bandDiener : .bandSchulung
+ ov.append([.text(group.title, band)] + Array(repeating: .text(" ", band), count: tasks.count + 1))
+ ov.append([.text("Name", .header), .text("Zuletzt (irgendeine)", .header)] + tasks.map { .text($0.name, .header) })
+ for r in rows {
+ ov.append([.text(r.person.name), r.any.map { .date($0) } ?? .text("nie", .center)] + r.cells.map {
+ switch $0 {
+ case .notAllowed: return .text("–", .center)
+ case .never: return .text("nie", .center)
+ case .date(let s): return .date(s)
+ }
+ })
+ }
+ ov.append([])
+ }
+ let hist: [[C]] = [["Datum", "Aufgabe", "Person", "Partner", "Thema / Notiz"].map { .text($0, .header) }]
+ + store.db.entries.sorted { $0.date > $1.date }.map { e in
+ [.date(e.date), .text(store.task(e.task)?.name ?? e.task), .text(store.name(e.person)),
+ .text(e.partner.isEmpty ? "" : store.name(e.partner)), .text(e.note)]
+ }
+ let pers: [[C]] = [(["Name", "Bruder/Schwester", "Notiz"] + store.db.tasks.map(\.name)).map { .text($0, .header) }]
+ + store.personsSorted.map { p in
+ [.text(p.name), .text(p.gender), .text(p.note)] + store.db.tasks.map { p.may($0.id) ? .text("x", .center) : .text("") }
+ }
+ return [
+ .init(name: "Übersicht", rows: ov, widths: [26, 13] + Array(repeating: 14, count: maxCols - 2)),
+ .init(name: "Verlauf", rows: hist, widths: [11, 26, 24, 24, 40]),
+ .init(name: "Personen", rows: pers, widths: [24, 14, 24] + store.db.tasks.map { _ in 9 }),
+ ]
+ }
+}
+
+/// Minimaler .xlsx-Schreiber (ZIP ohne Kompression), ohne Fremdbibliothek.
+enum XLSXWriter {
+ enum Style: Int { case normal = 0, date = 1, header = 2, title = 3, bandDiener = 4, bandSchulung = 5, center = 6 }
+ enum Cell { case text(String, Style = .normal), date(String) }
+ struct Sheet { let name: String; let rows: [[Cell]]; let widths: [Double] }
+
+ static func workbook(sheets: [Sheet]) -> Data {
+ var files: [(String, String)] = [
+ ("[Content_Types].xml", #""#
+ + sheets.indices.map { #""# }.joined() + ""),
+ ("_rels/.rels", #""#),
+ ("xl/workbook.xml", #""#
+ + sheets.enumerated().map { #""# }.joined() + ""),
+ ("xl/_rels/workbook.xml.rels", #""#
+ + sheets.indices.map { #""# }.joined()
+ + #""#),
+ ("xl/styles.xml", #""#),
+ ]
+ for (i, s) in sheets.enumerated() { files.append(("xl/worksheets/sheet\(i + 1).xml", sheetXML(s))) }
+ return zip(files.map { ($0.0, Data($0.1.utf8)) })
+ }
+
+ private static func sheetXML(_ s: Sheet) -> String {
+ let cols = s.widths.enumerated().map { #""# }.joined()
+ var out = #"\#(cols)"#
+ for (r, row) in s.rows.enumerated() {
+ out += #""#
+ for (c, cell) in row.enumerated() {
+ let ref = column(c) + "\(r + 1)"
+ switch cell {
+ case .text(let t, let st):
+ guard !t.isEmpty else { continue }
+ out += #"\#(esc(t))"#
+ case .date(let d):
+ out += #"\#(serial(d))"#
+ }
+ }
+ out += "
"
+ }
+ return out + ""
+ }
+
+ private static func serial(_ iso: String) -> Int {
+ let p = iso.split(separator: "-").compactMap { Int($0) }
+ guard p.count == 3 else { return 0 }
+ var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(identifier: "UTC")!
+ let d = cal.date(from: DateComponents(year: p[0], month: p[1], day: p[2]))!
+ let base = cal.date(from: DateComponents(year: 1899, month: 12, day: 30))!
+ return cal.dateComponents([.day], from: base, to: d).day ?? 0
+ }
+
+ private static func column(_ i: Int) -> String {
+ var n = i + 1, s = ""
+ while n > 0 { let m = (n - 1) % 26; s = String(UnicodeScalar(65 + m)!) + s; n = (n - 1) / 26 }
+ return s
+ }
+
+ private static func esc(_ s: String) -> String {
+ s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """)
+ }
+
+ // MARK: ZIP (stored)
+
+ private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
+ var c = UInt32(n)
+ for _ in 0..<8 { c = c & 1 != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1 }
+ return c
+ }
+ private static func crc32(_ d: Data) -> UInt32 {
+ var c: UInt32 = 0xFFFFFFFF
+ for b in d { c = crcTable[Int((c ^ UInt32(b)) & 0xFF)] ^ (c >> 8) }
+ return c ^ 0xFFFFFFFF
+ }
+
+ private static func zip(_ files: [(String, Data)]) -> Data {
+ var out = Data(), central = Data()
+ func u16(_ v: Int, _ d: inout Data) { withUnsafeBytes(of: UInt16(v).littleEndian) { d.append(contentsOf: $0) } }
+ func u32(_ v: UInt32, _ d: inout Data) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
+ for (name, body) in files {
+ let n = Data(name.utf8), crc = crc32(body), offset = UInt32(out.count)
+ u32(0x04034b50, &out); u16(20, &out); u16(0x0800, &out); u16(0, &out); u16(0, &out); u16(0, &out)
+ u32(crc, &out); u32(UInt32(body.count), &out); u32(UInt32(body.count), &out); u16(n.count, &out); u16(0, &out)
+ out.append(n); out.append(body)
+ u32(0x02014b50, ¢ral); u16(20, ¢ral); u16(20, ¢ral); u16(0x0800, ¢ral); u16(0, ¢ral)
+ u16(0, ¢ral); u16(0, ¢ral); u32(crc, ¢ral); u32(UInt32(body.count), ¢ral); u32(UInt32(body.count), ¢ral)
+ u16(n.count, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u32(0, ¢ral)
+ u32(offset, ¢ral); central.append(n)
+ }
+ let cdOffset = UInt32(out.count)
+ out.append(central)
+ u32(0x06054b50, &out); u16(0, &out); u16(0, &out); u16(files.count, &out); u16(files.count, &out)
+ u32(UInt32(central.count), &out); u32(cdOffset, &out); u16(0, &out)
+ return out
+ }
+}
diff --git a/App/Sources/HistoryView.swift b/App/Sources/HistoryView.swift
new file mode 100644
index 0000000..eb995ea
--- /dev/null
+++ b/App/Sources/HistoryView.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+struct HistoryRow: Identifiable {
+ let entry: Entry
+ let task: String
+ let person: String
+ let partner: String
+ var id: String { entry.id }
+ var date: String { entry.date }
+ var note: String { entry.note }
+}
+
+struct HistoryView: View {
+ @EnvironmentObject var store: Store
+ @EnvironmentObject var ui: UIState
+ @State private var query = ""
+ @State private var taskFilter = ""
+ @State private var selection = Set()
+ @State private var sortOrder = [KeyPathComparator(\HistoryRow.date, order: .reverse)]
+
+ private var rows: [HistoryRow] {
+ let q = query.lowercased()
+ return store.db.entries
+ .filter { taskFilter.isEmpty || $0.task == taskFilter }
+ .map { e in
+ HistoryRow(entry: e, task: store.task(e.task)?.name ?? e.task, person: store.name(e.person),
+ partner: e.partner.isEmpty ? "" : store.name(e.partner))
+ }
+ .filter { q.isEmpty || "\($0.task) \($0.person) \($0.partner) \($0.note)".lowercased().contains(q) }
+ .sorted(using: sortOrder)
+ }
+
+ var body: some View {
+ let rows = rows
+ VStack(spacing: 0) {
+ HStack(spacing: 12) {
+ TextField("Suchen (Name, Notiz …)", text: $query)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 260)
+ Picker("Aufgabe", selection: $taskFilter) {
+ Text("Alle Aufgaben").tag("")
+ ForEach(store.db.tasks.filter { !$0.partnerColumn }) { Text($0.name).tag($0.id) }
+ }
+ .frame(width: 280)
+ Text("\(rows.count) Einträge").foregroundStyle(Theme.muted)
+ Spacer()
+ Button("Bearbeiten") { edit(selection.first) }.disabled(selection.count != 1)
+ Button("Löschen", role: .destructive) { delete(selection) }.disabled(selection.isEmpty)
+ }
+ .padding(16)
+
+ Table(rows, selection: $selection, sortOrder: $sortOrder) {
+ TableColumn("Datum", value: \.date) { Text(Day.display($0.date)).monospacedDigit() }.width(90)
+ TableColumn("Aufgabe", value: \.task).width(min: 160, ideal: 220)
+ TableColumn("Person", value: \.person).width(min: 140, ideal: 200)
+ TableColumn("Partner", value: \.partner).width(min: 120, ideal: 180)
+ TableColumn("Thema / Notiz", value: \.note) { Text($0.note).foregroundStyle(Theme.muted) }
+ }
+ .contextMenu(forSelectionType: String.self) { ids in
+ if ids.count == 1 { Button("Bearbeiten") { edit(ids.first) } }
+ Button("Löschen", role: .destructive) { delete(ids) }
+ } primaryAction: { ids in edit(ids.first) }
+ .onDeleteCommand { delete(selection) }
+ .overlay {
+ if store.db.entries.isEmpty {
+ Text("Noch keine Einträge. Mit ⌘N oder einem Klick in die Übersicht geht's los.")
+ .foregroundStyle(Theme.muted)
+ }
+ }
+ }
+ }
+
+ private func edit(_ id: String?) {
+ guard let id, let e = store.db.entries.first(where: { $0.id == id }) else { return }
+ ui.draft = e
+ }
+
+ private func delete(_ ids: Set) {
+ guard !ids.isEmpty else { return }
+ let alert = NSAlert()
+ alert.messageText = ids.count == 1 ? "Eintrag löschen?" : "\(ids.count) Einträge löschen?"
+ alert.informativeText = "Das lässt sich nicht rückgängig machen."
+ alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
+ alert.addButton(withTitle: "Abbrechen")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ ids.forEach(store.delete(entry:))
+ selection.subtract(ids)
+ }
+}
diff --git a/App/Sources/MawariApp.swift b/App/Sources/MawariApp.swift
new file mode 100644
index 0000000..2ce1770
--- /dev/null
+++ b/App/Sources/MawariApp.swift
@@ -0,0 +1,115 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+enum Tab: String, CaseIterable, Identifiable {
+ case overview = "Übersicht", history = "Verlauf", persons = "Personen", tasks = "Aufgaben"
+ var id: String { rawValue }
+}
+
+/// Zustand der Oberfläche (welcher Bereich, welcher Eintrag wird gerade bearbeitet).
+@MainActor
+final class UIState: ObservableObject {
+ @Published var tab: Tab = .overview
+ @Published var draft: Entry?
+ @Published var toast: String?
+
+ func newEntry(task: String = "", person: String = "", partner: String = "") {
+ draft = Entry(id: "", date: Day.today, task: task, person: person, partner: partner)
+ }
+ func show(_ message: String) {
+ toast = message
+ Task { try? await Task.sleep(for: .seconds(3)); if toast == message { toast = nil } }
+ }
+}
+
+@main
+struct MawariApp: App {
+ @StateObject private var store = Store()
+ @StateObject private var ui = UIState()
+ @StateObject private var updates = UpdateController()
+
+ var body: some Scene {
+ Window("Mawari", id: "main") {
+ ContentView()
+ .environmentObject(store)
+ .environmentObject(ui)
+ .frame(minWidth: 960, minHeight: 600)
+ }
+ .defaultSize(width: 1280, height: 820)
+ .commands {
+ CommandGroup(after: .appInfo) {
+ Button("Nach Updates suchen …") { updates.checkForUpdates() }
+ .disabled(!updates.canCheck)
+ }
+ CommandGroup(replacing: .newItem) {
+ Button("Neuer Eintrag") { ui.newEntry() }.keyboardShortcut("n")
+ }
+ CommandGroup(replacing: .importExport) {
+ Button("Export für Numbers …") { Exporter.exportXlsx(store: store, ui: ui) }.keyboardShortcut("e")
+ Divider()
+ Button("Sicherung speichern …") { Exporter.saveBackup(store: store, ui: ui) }
+ Button("Sicherung / HTML-Daten laden …") { Exporter.importBackup(store: store, ui: ui) }
+ Button("Datenordner im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([store.folder]) }
+ }
+ CommandMenu("Ansicht") {
+ ForEach(Array(Tab.allCases.enumerated()), id: \.element) { i, tab in
+ Button(tab.rawValue) { ui.tab = tab }.keyboardShortcut(KeyEquivalent(Character("\(i + 1)")))
+ }
+ }
+ }
+ }
+}
+
+struct ContentView: View {
+ @EnvironmentObject var store: Store
+ @EnvironmentObject var ui: UIState
+
+ var body: some View {
+ Group {
+ switch ui.tab {
+ case .overview: OverviewView()
+ case .history: HistoryView()
+ case .persons: PersonsView()
+ case .tasks: TasksView()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Picker("Bereich", selection: $ui.tab) {
+ ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
+ }
+ .pickerStyle(.segmented)
+ .frame(width: 380)
+ }
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button { Exporter.exportXlsx(store: store, ui: ui) } label: {
+ Label("Export für Numbers", systemImage: "tablecells")
+ }
+ .help("Aktuellen Stand als Tabelle für Numbers speichern (⌘E)")
+ Button { ui.newEntry() } label: { Label("Neuer Eintrag", systemImage: "plus") }
+ .help("Neuer Eintrag (⌘N)")
+ }
+ }
+ .sheet(item: $ui.draft) { draft in
+ EntrySheet(entry: draft)
+ }
+ .overlay(alignment: .bottom) {
+ if let t = ui.toast ?? store.lastError {
+ Text(t)
+ .font(.callout)
+ .padding(.horizontal, 14).padding(.vertical, 8)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
+ .padding(.bottom, 18)
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: ui.toast)
+ .tint(Theme.tokiwa)
+ }
+}
+
+extension Entry {
+ var isNew: Bool { id.isEmpty }
+}
diff --git a/App/Sources/Model.swift b/App/Sources/Model.swift
new file mode 100644
index 0000000..22e26c1
--- /dev/null
+++ b/App/Sources/Model.swift
@@ -0,0 +1,145 @@
+import Foundation
+
+// Datenformat ist bewusst identisch mit dem HTML-Tool (leben-und-dienst.json),
+// damit dessen Dateien direkt importiert und Sicherungen dort geöffnet werden können.
+
+enum TaskGroup: String, Codable, CaseIterable, Identifiable {
+ case diener, schulung
+ var id: String { rawValue }
+ var title: String { self == .diener ? "Aufgaben für Diener (Ernannte)" : "Schulungsaufgaben" }
+ var shortTitle: String { self == .diener ? "Diener (Ernannte)" : "Schulung" }
+}
+
+struct DutyTask: Codable, Identifiable, Hashable {
+ var id: String
+ var name: String
+ var group: TaskGroup
+ var withPartner: Bool = false
+ var isPartner: Bool? = nil
+
+ var partnerColumn: Bool { isPartner == true }
+}
+
+struct Person: Codable, Identifiable, Hashable {
+ var id: String
+ var name: String
+ var gender: String = ""
+ var note: String = ""
+ var tasks: [String] = []
+
+ func may(_ taskID: String) -> Bool { tasks.contains(taskID) }
+}
+
+struct Entry: Codable, Identifiable, Hashable {
+ var id: String
+ var date: String // YYYY-MM-DD
+ var task: String
+ var person: String
+ var partner: String = ""
+ var note: String = ""
+}
+
+struct Database: Codable {
+ var app = "leben-und-dienst-uebersicht"
+ var version = 1
+ var updated = ""
+ var tasks: [DutyTask]
+ var persons: [Person]
+ var entries: [Entry]
+
+ static let defaultTasks: [DutyTask] = ([
+ ("vorsitz", "Vorsitz", .diener, false), ("gebet", "Gebet", .diener, false),
+ ("schaetze", "Schätze aus Gottes Wort", .diener, false), ("graben", "Schätze graben", .diener, false),
+ ("leben", "Leben als Christ", .diener, false), ("vbs-leitung", "VBS-Leitung", .diener, false),
+ ("vbs-leser", "VBS-Leser", .diener, false), ("bibellesung", "Bibellesung", .schulung, false),
+ ("gespraeche", "Gespräche beginnen", .schulung, true), ("interesse", "Interesse fördern", .schulung, true),
+ ("juenger", "Menschen zu Jüngern machen", .schulung, true), ("glauben", "Glaubensansichten erklären", .schulung, true),
+ ("sagen", "Was würdest du sagen?", .schulung, true), ("vortrag", "Vortrag", .schulung, false),
+ ("partner", "Partner", .schulung, false),
+ ] as [(String, String, TaskGroup, Bool)]).map { DutyTask(id: $0.0, name: $0.1, group: $0.2, withPartner: $0.3, isPartner: $0.0 == "partner" ? true : nil) }
+
+ static var empty: Database { Database(tasks: defaultTasks, persons: [], entries: []) }
+
+ // Tolerant gegenüber Dateien des HTML-Tools mit fehlenden Feldern.
+ init(tasks: [DutyTask], persons: [Person], entries: [Entry]) {
+ self.tasks = tasks; self.persons = persons; self.entries = entries
+ }
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ app = try c.decodeIfPresent(String.self, forKey: .app) ?? app
+ version = try c.decodeIfPresent(Int.self, forKey: .version) ?? 1
+ updated = try c.decodeIfPresent(String.self, forKey: .updated) ?? ""
+ let t = try c.decodeIfPresent([DutyTask].self, forKey: .tasks) ?? []
+ tasks = t.isEmpty ? Database.defaultTasks : t
+ persons = try c.decode([Person].self, forKey: .persons)
+ entries = try c.decode([Entry].self, forKey: .entries)
+ }
+}
+
+extension Person {
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decode(String.self, forKey: .id)
+ name = try c.decode(String.self, forKey: .name)
+ gender = try c.decodeIfPresent(String.self, forKey: .gender) ?? ""
+ note = try c.decodeIfPresent(String.self, forKey: .note) ?? ""
+ tasks = try c.decodeIfPresent([String].self, forKey: .tasks) ?? []
+ }
+}
+
+extension Entry {
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decode(String.self, forKey: .id)
+ date = try c.decode(String.self, forKey: .date)
+ task = try c.decode(String.self, forKey: .task)
+ person = try c.decode(String.self, forKey: .person)
+ partner = try c.decodeIfPresent(String.self, forKey: .partner) ?? ""
+ note = try c.decodeIfPresent(String.self, forKey: .note) ?? ""
+ }
+}
+
+extension DutyTask {
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decode(String.self, forKey: .id)
+ name = try c.decode(String.self, forKey: .name)
+ group = (try? c.decode(TaskGroup.self, forKey: .group)) ?? .schulung
+ withPartner = try c.decodeIfPresent(Bool.self, forKey: .withPartner) ?? false
+ isPartner = try c.decodeIfPresent(Bool.self, forKey: .isPartner)
+ }
+}
+
+// MARK: - Datum
+
+enum Day {
+ nonisolated(unsafe) private static let iso: DateFormatter = {
+ let f = DateFormatter(); f.calendar = Calendar(identifier: .gregorian)
+ f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = .current; f.dateFormat = "yyyy-MM-dd"; return f
+ }()
+ nonisolated(unsafe) private static let short: DateFormatter = {
+ let f = DateFormatter(); f.locale = Locale(identifier: "de_DE"); f.dateFormat = "dd.MM.yy"; return f
+ }()
+
+ static func string(_ d: Date) -> String { iso.string(from: d) }
+ static func date(_ s: String) -> Date? { iso.date(from: s) }
+ static var today: String { string(Date()) }
+ static func display(_ s: String) -> String { date(s).map { short.string(from: $0) } ?? s }
+
+ static func weeksAgo(_ s: String) -> Int? {
+ guard let d = date(s), let t = date(today) else { return nil }
+ let days = Calendar.current.dateComponents([.day], from: d, to: t).day ?? 0
+ return days < 0 ? -1 : days / 7
+ }
+ static func agoText(_ s: String) -> String {
+ switch weeksAgo(s) {
+ case .none: return ""
+ case .some(let w) where w < 0: return "geplant"
+ case 0: return "diese Woche"
+ case 1: return "vor 1 Woche"
+ case .some(let w): return "vor \(w) Wochen"
+ }
+ }
+}
+
+func newID(_ prefix: String) -> String { prefix + UUID().uuidString.prefix(8).lowercased() }
diff --git a/App/Sources/OverviewView.swift b/App/Sources/OverviewView.swift
new file mode 100644
index 0000000..3e1ea77
--- /dev/null
+++ b/App/Sources/OverviewView.swift
@@ -0,0 +1,242 @@
+import SwiftUI
+
+/// Sortierung einer Übersichtstabelle. `column` ist "name", "any" oder eine Aufgaben-ID.
+struct OverviewSort: Equatable {
+ var column = "name"
+ var ascending = true
+}
+
+struct OverviewRow: Identifiable {
+ let person: Person
+ let any: String?
+ let cells: [Cell]
+ var id: String { person.id }
+
+ enum Cell { case notAllowed, never, date(String) }
+}
+
+/// Zeilen eines Bereichs – auch vom Export genutzt, damit er dieselbe Sortierung zeigt.
+@MainActor
+func overviewRows(store: Store, group: TaskGroup, sort: OverviewSort) -> (tasks: [DutyTask], rows: [OverviewRow]) {
+ let tasks = store.db.tasks.filter { $0.group == group }
+ let ids = Set(tasks.map(\.id))
+ let people = store.db.persons.filter { $0.tasks.contains(where: ids.contains) }
+ var rows = people.map { p in
+ OverviewRow(person: p, any: store.lastAny(person: p.id), cells: tasks.map { t in
+ guard p.may(t.id) else { return .notAllowed }
+ return store.lastDate(person: p.id, task: t).map { .date($0) } ?? .never
+ })
+ }
+ let byName: (OverviewRow, OverviewRow) -> Bool = { $0.person.name.localizedCompare($1.person.name) == .orderedAscending }
+ // "nie" zählt als am längsten her, "nicht freigegeben" steht immer am Ende.
+ func key(_ r: OverviewRow) -> String? {
+ if sort.column == "any" { return r.any ?? "0000" }
+ guard let i = tasks.firstIndex(where: { $0.id == sort.column }) else { return nil }
+ switch r.cells[i] {
+ case .notAllowed: return nil
+ case .never: return "0000"
+ case .date(let d): return d
+ }
+ }
+ rows.sort { a, b in
+ if sort.column == "name" { return sort.ascending ? byName(a, b) : byName(b, a) }
+ switch (key(a), key(b)) {
+ case (nil, nil): return byName(a, b)
+ case (nil, _): return false
+ case (_, nil): return true
+ case let (ka?, kb?):
+ if ka == kb { return byName(a, b) }
+ return sort.ascending ? ka < kb : ka > kb
+ }
+ }
+ return (tasks, rows)
+}
+
+struct OverviewView: View {
+ @EnvironmentObject var store: Store
+
+ var body: some View {
+ ScrollView(.vertical) {
+ VStack(alignment: .leading, spacing: 24) {
+ HStack(spacing: 16) {
+ legend(Text("Datum"), "letzte Einteilung")
+ legend(Text("nie").foregroundStyle(Theme.kohaku).bold(), "freigegeben, aber noch nie eingeteilt")
+ legend(Text("–").foregroundStyle(Theme.faint), "nicht freigegeben")
+ Spacer()
+ Text("Spaltenkopf klicken = sortieren · Zelle klicken = eintragen")
+ }
+ .font(.caption)
+ .foregroundStyle(Theme.muted)
+
+ if store.db.persons.isEmpty {
+ EmptyStart()
+ } else {
+ ForEach(TaskGroup.allCases) { OverviewSection(group: $0) }
+ }
+ }
+ .padding(24)
+ }
+ }
+
+ private func legend(_ symbol: Text, _ text: String) -> some View {
+ HStack(spacing: 4) { symbol; Text("= \(text)") }
+ }
+}
+
+struct EmptyStart: View {
+ @EnvironmentObject var store: Store
+ @EnvironmentObject var ui: UIState
+
+ var body: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "person.3.sequence")
+ .font(.system(size: 44, weight: .light))
+ .foregroundStyle(Theme.tokiwa)
+ Text("Noch niemand eingetragen.").font(.title2.weight(.semibold))
+ Text("Leg unter „Personen“ alle an, die mitmachen, und hak an, wer was übernehmen darf.")
+ .foregroundStyle(Theme.muted)
+ HStack {
+ Button("Personen anlegen") { ui.tab = .persons }.buttonStyle(.borderedProminent)
+ Button("Daten aus dem HTML-Tool laden …") { Exporter.importBackup(store: store, ui: ui) }
+ }
+ .padding(.top, 6)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 80)
+ }
+}
+
+struct OverviewSection: View {
+ let group: TaskGroup
+ @EnvironmentObject var store: Store
+ @EnvironmentObject var ui: UIState
+ @AppStorage private var sortColumn: String
+ @AppStorage private var sortAscending: Bool
+
+ init(group: TaskGroup) {
+ self.group = group
+ _sortColumn = AppStorage(wrappedValue: "name", "sort.\(group.rawValue).column")
+ _sortAscending = AppStorage(wrappedValue: true, "sort.\(group.rawValue).ascending")
+ }
+
+ private let nameWidth: CGFloat = 210
+ private let anyWidth: CGFloat = 100
+ private let cellWidth: CGFloat = 108
+
+ var body: some View {
+ let sort = OverviewSort(column: sortColumn, ascending: sortAscending)
+ let (tasks, rows) = overviewRows(store: store, group: group, sort: sort)
+ let color = Theme.color(for: group)
+
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 10) {
+ Text(group.title).font(.headline)
+ Text("\(rows.count) Personen").font(.subheadline).opacity(0.8)
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 14).padding(.vertical, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(color)
+
+ ScrollView(.horizontal) {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 0) {
+ header("Name", column: "name", width: nameWidth, alignment: .leading)
+ header("Zuletzt (irgendeine)", column: "any", width: anyWidth)
+ ForEach(tasks) { header($0.name, column: $0.id, width: cellWidth) }
+ }
+ .background(Theme.soft(for: group))
+
+ if rows.isEmpty {
+ Text("Noch niemand freigegeben – unter „Personen“ Häkchen setzen.")
+ .foregroundStyle(Theme.muted)
+ .padding(14)
+ }
+ ForEach(rows) { row in
+ HStack(spacing: 0) {
+ Text(row.person.name)
+ .fontWeight(.medium)
+ .lineLimit(1)
+ .padding(.horizontal, 12)
+ .frame(width: nameWidth, alignment: .leading)
+ Text(row.any.map(Day.display) ?? "nie")
+ .monospacedDigit()
+ .foregroundStyle(Theme.muted)
+ .frame(width: anyWidth)
+ ForEach(Array(zip(tasks, row.cells)), id: \.0.id) { task, cell in
+ OverviewCell(cell: cell, width: cellWidth) {
+ if task.partnerColumn { ui.newEntry(partner: row.person.id) }
+ else { ui.newEntry(task: task.id, person: row.person.id) }
+ }
+ .help("\(task.name) · \(row.person.name)")
+ }
+ }
+ .frame(height: 44)
+ .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
+ }
+ }
+ }
+ }
+ .background(Color(nsColor: .controlBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.line))
+ }
+
+ private func header(_ title: String, column: String, width: CGFloat, alignment: Alignment = .center) -> some View {
+ Button {
+ if sortColumn == column { sortAscending.toggle() } else { sortColumn = column; sortAscending = true }
+ } label: {
+ HStack(spacing: 3) {
+ if sortColumn == column {
+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(Theme.color(for: group))
+ }
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .multilineTextAlignment(alignment == .leading ? .leading : .center)
+ .lineLimit(2)
+ }
+ .foregroundStyle(sortColumn == column ? Color.primary : Theme.muted)
+ .padding(.horizontal, alignment == .leading ? 12 : 6)
+ .frame(width: width, height: 46, alignment: alignment == .leading ? .bottomLeading : .bottom)
+ .padding(.bottom, 6)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help(column == "name" ? "Nach Name sortieren" : "Sortieren: wer am längsten nicht dran war, steht oben")
+ }
+}
+
+struct OverviewCell: View {
+ let cell: OverviewRow.Cell
+ let width: CGFloat
+ let action: () -> Void
+ @State private var hover = false
+
+ var body: some View {
+ switch cell {
+ case .notAllowed:
+ Text("–").foregroundStyle(Theme.faint).frame(width: width, height: 44)
+ case .never:
+ Button(action: action) {
+ Text("nie").fontWeight(.medium).foregroundStyle(Theme.kohaku)
+ .frame(width: width, height: 44)
+ .background(hover ? Theme.kohaku.opacity(0.22) : Theme.kohakuSoft)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain).onHover { hover = $0 }
+ case .date(let d):
+ Button(action: action) {
+ VStack(spacing: 1) {
+ Text(Day.display(d)).monospacedDigit()
+ Text(Day.agoText(d)).font(.caption2).foregroundStyle(Theme.muted)
+ }
+ .frame(width: width, height: 44)
+ .background(hover ? Color.primary.opacity(0.06) : .clear)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain).onHover { hover = $0 }
+ }
+ }
+}
diff --git a/App/Sources/PersonsView.swift b/App/Sources/PersonsView.swift
new file mode 100644
index 0000000..4398c8c
--- /dev/null
+++ b/App/Sources/PersonsView.swift
@@ -0,0 +1,143 @@
+import SwiftUI
+
+struct PersonsView: View {
+ @EnvironmentObject var store: Store
+ @State private var newName = ""
+ @FocusState private var newFocused: Bool
+
+ private let nameWidth: CGFloat = 220
+ private let genderWidth: CGFloat = 120
+ private let noteWidth: CGFloat = 180
+ private let checkWidth: CGFloat = 32
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 10) {
+ TextField("Name der neuen Person", text: $newName)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 260)
+ .focused($newFocused)
+ .onSubmit(add)
+ Button("Hinzufügen", action: add).buttonStyle(.borderedProminent)
+ .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
+ Text("Häkchen = darf diese Aufgabe übernehmen. Wer mindestens eine Diener-Aufgabe hat, erscheint oben in der Übersicht, wer eine Schulungsaufgabe hat, unten.")
+ .font(.caption).foregroundStyle(Theme.muted)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(16)
+
+ ScrollView([.horizontal, .vertical]) {
+ VStack(alignment: .leading, spacing: 0) {
+ headerRow
+ ForEach(store.personsSorted) { p in
+ if let binding = store.binding(person: p.id) {
+ PersonRow(person: binding, widths: (nameWidth, genderWidth, noteWidth, checkWidth))
+ }
+ }
+ }
+ .padding([.horizontal, .bottom], 16)
+ }
+ }
+ }
+
+ private var headerRow: some View {
+ HStack(alignment: .bottom, spacing: 0) {
+ Text("Name").frame(width: nameWidth, alignment: .leading).padding(.leading, 4)
+ Text("Bruder/Schwester").frame(width: genderWidth, alignment: .leading)
+ Text("Notiz").frame(width: noteWidth, alignment: .leading)
+ ForEach(store.db.tasks) { t in
+ Text(t.name)
+ .lineLimit(1)
+ .fixedSize()
+ .rotationEffect(.degrees(-90))
+ .frame(width: checkWidth, height: 170, alignment: .center)
+ .background(Theme.soft(for: t.group))
+ .help(t.name)
+ }
+ Spacer().frame(width: 80)
+ }
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Theme.muted)
+ .padding(.bottom, 6)
+ .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
+ }
+
+ private func add() {
+ if store.addPerson(named: newName) { newName = "" } else { NSSound.beep() }
+ newFocused = true
+ }
+}
+
+struct PersonRow: View {
+ @Binding var person: Person
+ let widths: (name: CGFloat, gender: CGFloat, note: CGFloat, check: CGFloat)
+ @EnvironmentObject var store: Store
+ @State private var name = ""
+ @FocusState private var focused: Bool
+
+ var body: some View {
+ HStack(spacing: 0) {
+ TextField("Name", text: $name)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: widths.name - 12)
+ .padding(.trailing, 12)
+ .onAppear { name = person.name }
+ .focused($focused)
+ .onSubmit(commitName)
+ .onChange(of: focused) { _, now in if !now { commitName() } }
+ .onDisappear(perform: commitName)
+ Picker("", selection: $person.gender) {
+ Text("–").tag("")
+ Text("Bruder").tag("Bruder")
+ Text("Schwester").tag("Schwester")
+ }
+ .labelsHidden()
+ .frame(width: widths.gender - 12)
+ .padding(.trailing, 12)
+ TextField("", text: $person.note)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: widths.note - 12)
+ .padding(.trailing, 12)
+ ForEach(store.db.tasks) { t in
+ Toggle("", isOn: Binding(
+ get: { person.tasks.contains(t.id) },
+ set: { on in
+ if on { if !person.tasks.contains(t.id) { person.tasks.append(t.id) } }
+ else { person.tasks.removeAll { $0 == t.id } }
+ }))
+ .toggleStyle(.checkbox)
+ .labelsHidden()
+ .frame(width: widths.check)
+ .help(t.name)
+ }
+ Button("Löschen", role: .destructive, action: delete)
+ .buttonStyle(.link)
+ .foregroundStyle(Theme.bengara)
+ .frame(width: 80)
+ }
+ .padding(.vertical, 5)
+ .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
+ }
+
+ private func commitName() {
+ let n = name.trimmingCharacters(in: .whitespaces)
+ guard n != person.name else { return }
+ if n.isEmpty || store.db.persons.contains(where: { $0.id != person.id && $0.name.lowercased() == n.lowercased() }) {
+ NSSound.beep(); name = person.name; return
+ }
+ person.name = n
+ }
+
+ private func delete() {
+ let count = store.entryCount(person: person.id)
+ let alert = NSAlert()
+ alert.messageText = "\(person.name) löschen?"
+ alert.informativeText = count > 0
+ ? "\(count) Einträge im Verlauf gehören zu dieser Person und werden ebenfalls gelöscht."
+ : "Die Person hat noch keine Einträge im Verlauf."
+ alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
+ alert.addButton(withTitle: "Abbrechen")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ store.deletePerson(person.id)
+ }
+}
diff --git a/App/Sources/Store.swift b/App/Sources/Store.swift
new file mode 100644
index 0000000..acee4e6
--- /dev/null
+++ b/App/Sources/Store.swift
@@ -0,0 +1,139 @@
+import Foundation
+import SwiftUI
+
+/// Hält alle Daten und speichert jede Änderung sofort in
+/// ~/Library/Application Support/Mawari/daten.json. Einmal am Tag wird eine
+/// Sicherungskopie in "Sicherungen" abgelegt (die letzten 60 bleiben).
+@MainActor
+final class Store: ObservableObject {
+ @Published var db: Database { didSet { scheduleSave() } }
+ @Published var lastError: String?
+
+ let folder: URL
+ private var file: URL { folder.appendingPathComponent("daten.json") }
+ private var backups: URL { folder.appendingPathComponent("Sicherungen") }
+ private var saveTask: Task?
+
+ init() {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ // MAWARI_DATA_DIR: nur zum Testen, damit echte Daten unberührt bleiben.
+ if let dir = ProcessInfo.processInfo.environment["MAWARI_DATA_DIR"] {
+ folder = URL(fileURLWithPath: dir, isDirectory: true)
+ } else {
+ folder = base.appendingPathComponent("Mawari", isDirectory: true)
+ }
+ try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ if let data = try? Data(contentsOf: folder.appendingPathComponent("daten.json")),
+ let loaded = try? JSONDecoder().decode(Database.self, from: data) {
+ db = loaded
+ } else {
+ db = .empty
+ }
+ dailyBackup()
+ }
+
+ // MARK: Speichern
+
+ private func scheduleSave() {
+ saveTask?.cancel()
+ saveTask = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(300))
+ guard !Task.isCancelled else { return }
+ self?.saveNow()
+ }
+ }
+
+ func saveNow() {
+ do {
+ try encoded().write(to: file, options: .atomic)
+ lastError = nil
+ } catch {
+ lastError = "Speichern fehlgeschlagen: \(error.localizedDescription)"
+ }
+ }
+
+ func encoded() throws -> Data {
+ var copy = db
+ copy.updated = ISO8601DateFormatter().string(from: Date())
+ let enc = JSONEncoder()
+ enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ return try enc.encode(copy)
+ }
+
+ private func dailyBackup() {
+ guard FileManager.default.fileExists(atPath: file.path) else { return }
+ try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
+ let target = backups.appendingPathComponent("daten-\(Day.today).json")
+ if !FileManager.default.fileExists(atPath: target.path) {
+ try? FileManager.default.copyItem(at: file, to: target)
+ }
+ let all = (try? FileManager.default.contentsOfDirectory(at: backups, includingPropertiesForKeys: nil)) ?? []
+ for old in all.filter({ $0.pathExtension == "json" }).sorted(by: { $0.lastPathComponent > $1.lastPathComponent }).dropFirst(60) {
+ try? FileManager.default.removeItem(at: old)
+ }
+ }
+
+ // MARK: Import / Sicherung
+
+ func importFile(_ url: URL) throws {
+ let access = url.startAccessingSecurityScopedResource()
+ defer { if access { url.stopAccessingSecurityScopedResource() } }
+ let data = try Data(contentsOf: url)
+ let loaded = try JSONDecoder().decode(Database.self, from: data)
+ saveNow() // aktuellen Stand vorher sichern
+ let safety = backups.appendingPathComponent("vor-import-\(Int(Date().timeIntervalSince1970)).json")
+ try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
+ try? FileManager.default.copyItem(at: file, to: safety)
+ db = loaded
+ }
+
+ // MARK: Abfragen
+
+ func task(_ id: String) -> DutyTask? { db.tasks.first { $0.id == id } }
+ func person(_ id: String) -> Person? { db.persons.first { $0.id == id } }
+ func name(_ id: String) -> String { person(id)?.name ?? "(gelöscht)" }
+ var personsSorted: [Person] { db.persons.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } }
+
+ func lastDate(person: String, task: DutyTask) -> String? {
+ var best: String?
+ for e in db.entries {
+ let hit = task.partnerColumn ? e.partner == person : (e.task == task.id && e.person == person)
+ if hit, e.date > (best ?? "") { best = e.date }
+ }
+ return best
+ }
+
+ func lastAny(person: String) -> String? {
+ var best: String?
+ for e in db.entries where (e.person == person || e.partner == person) && e.date > (best ?? "") { best = e.date }
+ return best
+ }
+
+ // MARK: Ändern
+
+ func save(entry: Entry) {
+ if let i = db.entries.firstIndex(where: { $0.id == entry.id }) { db.entries[i] = entry } else { db.entries.append(entry) }
+ }
+ func delete(entry id: String) { db.entries.removeAll { $0.id == id } }
+
+ func addPerson(named name: String) -> Bool {
+ let n = name.trimmingCharacters(in: .whitespaces)
+ guard !n.isEmpty, !db.persons.contains(where: { $0.name.lowercased() == n.lowercased() }) else { return false }
+ db.persons.append(Person(id: newID("p"), name: n))
+ return true
+ }
+ func deletePerson(_ id: String) {
+ db.entries.removeAll { $0.person == id }
+ for i in db.entries.indices where db.entries[i].partner == id { db.entries[i].partner = "" }
+ db.persons.removeAll { $0.id == id }
+ }
+ func entryCount(person id: String) -> Int { db.entries.filter { $0.person == id || $0.partner == id }.count }
+ func entryCount(task: DutyTask) -> Int { task.partnerColumn ? db.entries.filter { !$0.partner.isEmpty }.count : db.entries.filter { $0.task == task.id }.count }
+
+ func binding(person id: String) -> Binding? {
+ guard db.persons.contains(where: { $0.id == id }) else { return nil }
+ return Binding(
+ get: { self.db.persons.first { $0.id == id } ?? Person(id: id, name: "") },
+ set: { new in if let i = self.db.persons.firstIndex(where: { $0.id == id }) { self.db.persons[i] = new } })
+ }
+}
diff --git a/App/Sources/TasksView.swift b/App/Sources/TasksView.swift
new file mode 100644
index 0000000..2d0dede
--- /dev/null
+++ b/App/Sources/TasksView.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+struct TasksView: View {
+ @EnvironmentObject var store: Store
+ @State private var newName = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 10) {
+ TextField("Neue Aufgabe", text: $newName)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 260)
+ .onSubmit(add)
+ Button("Hinzufügen", action: add).buttonStyle(.borderedProminent)
+ .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
+ Text("Reihenfolge = Spalten in der Übersicht (ziehen zum Umsortieren). „Mit Partner“ blendet beim Eintragen das Partner-Feld ein.")
+ .font(.caption).foregroundStyle(Theme.muted)
+ }
+ .padding(16)
+
+ List {
+ ForEach($store.db.tasks) { $task in
+ HStack(spacing: 16) {
+ TextField("Name", text: $task.name)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 260)
+ Picker("", selection: $task.group) {
+ ForEach(TaskGroup.allCases) { Text($0.shortTitle).tag($0) }
+ }
+ .labelsHidden()
+ .frame(width: 170)
+ if task.partnerColumn {
+ Text("zählt die Partner").foregroundStyle(Theme.muted).frame(width: 120, alignment: .leading)
+ } else {
+ Toggle("Mit Partner", isOn: $task.withPartner).frame(width: 120, alignment: .leading)
+ }
+ Text(count(store.entryCount(task: task))).foregroundStyle(Theme.muted).frame(width: 90, alignment: .leading)
+ Spacer()
+ if !task.partnerColumn {
+ Button("Löschen", role: .destructive) { delete(task) }
+ .buttonStyle(.link).foregroundStyle(Theme.bengara)
+ }
+ }
+ .padding(.vertical, 3)
+ }
+ .onMove { store.db.tasks.move(fromOffsets: $0, toOffset: $1) }
+ }
+ }
+ }
+
+ private func count(_ n: Int) -> String { n == 1 ? "1 Eintrag" : "\(n) Einträge" }
+
+ private func add() {
+ let n = newName.trimmingCharacters(in: .whitespaces)
+ guard !n.isEmpty else { return }
+ let at = store.db.tasks.firstIndex(where: \.partnerColumn) ?? store.db.tasks.endIndex
+ store.db.tasks.insert(DutyTask(id: newID("t"), name: n, group: .schulung), at: at)
+ newName = ""
+ }
+
+ private func delete(_ task: DutyTask) {
+ let n = store.entryCount(task: task)
+ let alert = NSAlert()
+ if n > 0 {
+ alert.messageText = "„\(task.name)“ hat \(n) Einträge im Verlauf."
+ alert.informativeText = "Aufgaben mit Einträgen lassen sich nicht löschen. Du kannst sie umbenennen."
+ alert.runModal(); return
+ }
+ alert.messageText = "Aufgabe „\(task.name)“ löschen?"
+ alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
+ alert.addButton(withTitle: "Abbrechen")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ store.db.tasks.removeAll { $0.id == task.id }
+ for i in store.db.persons.indices { store.db.persons[i].tasks.removeAll { $0 == task.id } }
+ }
+}
diff --git a/App/Sources/Theme.swift b/App/Sources/Theme.swift
new file mode 100644
index 0000000..438fd34
--- /dev/null
+++ b/App/Sources/Theme.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+import AppKit
+
+/// Farbwelt von Mawari: Diensttafeln aus Holz (Hinoki), immergrünes Tokiwa als
+/// Markenfarbe, Kastanie (Kuri) für die Schulungsaufgaben. Hell ist Standard,
+/// dunkel wird unterstützt. Siehe design/README.md.
+enum Theme {
+ static let tokiwa = adaptive(light: 0x2F6B4F, dark: 0x6DBB93) // Marke, Diener-Bereich
+ static let kuri = adaptive(light: 0x7A5230, dark: 0xD0A07A) // Schulungsbereich
+ static let tokiwaSoft = adaptive(light: 0xE4EFE8, dark: 0x1F2D26)
+ static let kuriSoft = adaptive(light: 0xF2E9DF, dark: 0x2E2620)
+ static let kohaku = adaptive(light: 0xB45309, dark: 0xF0A35A) // "nie"
+ static let kohakuSoft = adaptive(light: 0xFDF1E3, dark: 0x3A2A18)
+ static let line = adaptive(light: 0xE4E0D8, dark: 0x30332F)
+ static let muted = adaptive(light: 0x7A746B, dark: 0x9A958C)
+ static let faint = adaptive(light: 0xC6C0B6, dark: 0x4C4F4A)
+ static let bengara = adaptive(light: 0xB5452B, dark: 0xE8775D) // Warnungen
+
+ static func color(for group: TaskGroup) -> Color { group == .diener ? tokiwa : kuri }
+ static func soft(for group: TaskGroup) -> Color { group == .diener ? tokiwaSoft : kuriSoft }
+
+ static func adaptive(light: UInt32, dark: UInt32) -> Color {
+ Color(nsColor: NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ return NSColor(hex: isDark ? dark : light)
+ })
+ }
+}
+
+extension NSColor {
+ convenience init(hex: UInt32) {
+ self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
+ }
+}
diff --git a/App/Sources/UpdateController.swift b/App/Sources/UpdateController.swift
new file mode 100644
index 0000000..92a38f9
--- /dev/null
+++ b/App/Sources/UpdateController.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Sparkle
+
+/// Selbstaktualisierung über Sparkle. Mawari sucht einmal am Tag von selbst
+/// (SUEnableAutomaticChecks in project.yml) und bietet gefundene Updates an;
+/// installiert wird erst nach Zustimmung. Appcast: update.zielke.studio/mawari.
+@MainActor
+final class UpdateController: ObservableObject {
+ private let controller: SPUStandardUpdaterController
+ @Published private(set) var canCheck = true
+ private var observation: NSKeyValueObservation?
+
+ init() {
+ controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
+ observation = controller.updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in
+ let can = updater.canCheckForUpdates
+ Task { @MainActor in self?.canCheck = can }
+ }
+ }
+
+ func checkForUpdates() { controller.updater.checkForUpdates() }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..36d1c84
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+## 1.0.0 (Build 1) – 2026-10-22
+
+Erste Version.
+
+- Übersicht in zwei Bereichen (Diener/Ernannte, Schulung): je Person und Aufgabe das letzte Datum, „nie“ oder „–“
+- Sortieren per Spaltenkopf, Eintrag per Klick auf eine Zelle mit Person und Aufgabe vorbelegt
+- Verlauf, Personen mit Freigaben, Aufgaben pflegen
+- Export für Numbers (.xlsx)
+- Sicherung speichern/laden, Übernahme der Daten aus dem früheren HTML-Tool
+- Automatisches Speichern, tägliche Sicherungskopie (60 Tage)
+- Selbstaktualisierung über Sparkle (update.zielke.studio/mawari)
+- Signiert und notarisiert, Universal (Apple Silicon + Intel), macOS 14+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..64cd59f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,23 @@
+# CLAUDE.md – Mawari
+
+Du arbeitest an **Mawari**, einer kleinen nativen macOS-App von Zielke Studio. Sie zeigt für die Leben-und-Dienst-Zusammenkunft, wer wann zuletzt welche Aufgabe hatte. Die offizielle Einteilung bleibt in NW Scheduler; Mawari ist nur das Nachschlagewerk daneben. Nutzer ist eine einzelne Person ohne Technikinteresse – alles muss ohne Erklärung funktionieren.
+
+## Technische Leitplanken
+
+- **Plattform:** macOS 14+, Universal Build (arm64 + x86_64), Swift 6, SwiftUI (AppKit nur für Panels und Alerts).
+- **Projekt:** `Mawari.xcodeproj` wird aus `project.yml` erzeugt (`xcodegen`) und ist nicht im Repo. `App/Info.plist` ebenso.
+- **Abhängigkeiten:** nur Sparkle 2.10.0 (gleiche Fassung wie drop.look/drop.two). Keine weiteren Pakete ohne Rückfrage.
+- **Datenformat:** muss kompatibel zum früheren HTML-Tool bleiben (`docs/datenformat.md`). Neue Felder nur optional und mit Standardwert dekodieren.
+- **Echte Daten nie anfassen:** Zum Testen `MAWARI_DATA_DIR=/tmp/irgendwas` setzen.
+- **Bewusst nicht im Umfang:** Vorschläge, wer als Nächstes dran ist; Abwesenheiten; Live-Verknüpfung mit NW Scheduler.
+
+## Design
+
+Farben in `App/Sources/Theme.swift`, Begründung in `design/README.md`. Hell ist Standard, dunkel wird unterstützt. Texte auf Deutsch, kurz, in Du-Form.
+
+## Arbeitsweise
+
+1. Vor Code: kurzer Plan in wenigen Punkten.
+2. Nach jedem lauffähigen Stand committen (Conventional Commits auf Englisch).
+3. Kommentare im Code auf Deutsch (wie bisher), UI-Texte auf Deutsch.
+4. Neue Version: Ablauf in `README.md` → „Neue Version veröffentlichen“, Eintrag in `CHANGELOG.md`.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..432ade4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,89 @@
+# Mawari
+
+**Wer war wann dran? Auf einen Blick.**
+
+Mawari (回り, japanisch: „reihum, die Runde“) ist eine macOS-App von Zielke Studio für die Planung der Leben-und-Dienst-Zusammenkunft. Die offizielle Einteilung läuft weiter über die bestehende App (NW Scheduler). Mawari ergänzt sie um die eine Frage, die dort untergeht: **Wann hatte wer zuletzt welche Aufgabe?**
+
+- Übersicht in zwei Bereichen: Aufgaben für Diener (Ernannte) und Schulungsaufgaben
+- je Person und Aufgabe das letzte Datum, „nie“ oder „–“ (nicht freigegeben)
+- Spaltenkopf klicken = sortieren (wer am längsten nicht dran war, steht oben)
+- Zelle klicken = Eintrag mit Person und Aufgabe vorbelegt
+- Export für Numbers (.xlsx, öffnet sich direkt in Numbers)
+- bewusst **keine** Vorschläge, keine Abwesenheiten, keine Verknüpfung mit der App
+
+## Dokumente
+
+| Datei | Inhalt |
+|---|---|
+| [CLAUDE.md](CLAUDE.md) | Arbeitsanweisungen für Claude Code |
+| [CHANGELOG.md](CHANGELOG.md) | Versionen |
+| [docs/datenformat.md](docs/datenformat.md) | Aufbau von `daten.json`, Sicherungen, Kompatibilität zum HTML-Tool |
+| [design/README.md](design/README.md) | Farben, Symbol, Texte |
+
+## Daten
+
+`~/Library/Application Support/Mawari/daten.json`, jede Änderung wird sofort gespeichert. Einmal täglich landet eine Kopie in `Sicherungen/` (die letzten 60 bleiben). Das Format ist identisch mit dem früheren HTML-Tool (`leben-und-dienst.json`), dessen Dateien lassen sich über *Ablage › Sicherung / HTML-Daten laden …* übernehmen.
+
+Zum Testen ohne echte Daten: `MAWARI_DATA_DIR=/irgendein/ordner …/Mawari.app/Contents/MacOS/Mawari`.
+
+## Bauen
+
+```bash
+brew install xcodegen # einmalig
+xcodegen # erzeugt Mawari.xcodeproj aus project.yml
+open Mawari.xcodeproj
+```
+
+macOS 14+, Universal (arm64 + x86_64), Swift 6, SwiftUI. Einzige Abhängigkeit: Sparkle 2.10.0.
+Symbol neu zeichnen: `swift Tools/make-icon.swift`.
+
+Das `.xcodeproj` und `App/Info.plist` sind generiert und nicht im Repo. Nach Änderungen an `project.yml` erneut `xcodegen` ausführen.
+
+Signiert wird mit **Developer ID Application** (Team `XY4C2RG3L5`), Hardened Runtime an. Ohne dieses Zertifikat `CODE_SIGN_IDENTITY`/`DEVELOPMENT_TEAM` in `project.yml` anpassen.
+
+## Struktur
+
+| Pfad | Inhalt |
+|---|---|
+| `App/Sources/MawariApp.swift` | Einstieg, Fenster, Menüs, Oberflächenzustand |
+| `App/Sources/Model.swift` | Datenmodell (Aufgaben, Personen, Einträge), Datumshilfen |
+| `App/Sources/Store.swift` | Laden, Speichern, tägliche Sicherung, Abfragen |
+| `App/Sources/OverviewView.swift` | Übersicht „wer war wann dran“ mit Sortierung |
+| `App/Sources/HistoryView.swift`, `EntrySheet.swift` | Verlauf und Eintrag bearbeiten |
+| `App/Sources/PersonsView.swift`, `TasksView.swift` | Personen (mit Freigaben) und Aufgaben pflegen |
+| `App/Sources/Exporter.swift` | .xlsx-Export (eigener Writer, ohne Abhängigkeit), Sicherung speichern/laden |
+| `App/Sources/Theme.swift` | Farben |
+| `App/Sources/UpdateController.swift` | Sparkle |
+| `Tools/` | Symbol erzeugen, DMG bauen, Appcast, Veröffentlichen |
+| `design/` | Symbolvorlage und Designnotizen |
+| `project.yml` | XcodeGen-Projektdefinition |
+
+## Neue Version veröffentlichen
+
+1. In `project.yml` `MARKETING_VERSION` erhöhen und `CURRENT_PROJECT_VERSION` um 1 hochzählen.
+2. `bash Tools/build-dmg.sh` – Release-Build, Signatur (Developer ID), Notarisierung von App und DMG.
+3. `bash Tools/make-appcast.sh "Was ist neu – ein Satz"` – signiert das DMG mit dem Sparkle-Schlüssel, schreibt `build/appcast.xml`.
+4. `bash Tools/publish.sh` – lädt DMG und Appcast nach `update.zielke.studio/mawari/`.
+
+Installierte Kopien suchen einmal am Tag nach Updates und bieten sie an. Von Hand: *Mawari › Nach Updates suchen …*
+
+**Einmalig vor dem ersten Veröffentlichen:** Ordner auf dem Server anlegen (siehe `zielke-apps/README.md`):
+
+```bash
+ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari'
+```
+
+**Schlüssel sichern:** Der private Sparkle-Schlüssel liegt im Schlüsselbund dieses Macs (Dienst `https://sparkle-project.org`, Konto `mawari`). Geht er verloren, kann keine installierte Kopie mehr aktualisiert werden. Export: `generate_keys --account mawari -x mawari-sparkle.key` (liegt nach dem ersten Build unter `build/dd/SourcePackages/artifacts/sparkle/Sparkle/bin/`), Datei in die Passwörter-App, dann löschen.
+
+Notarisierung nutzt das Profil `patchcord-notary` (gleiches Team). Anderes Profil: `NOTARY_PROFILE=… bash Tools/build-dmg.sh`.
+
+## Ausblick: Anbindung an NW Scheduler
+
+Geprüft, noch nicht umgesetzt. Eine Live-Schnittstelle (API) bietet NW Scheduler nicht. Möglich ist ein **Import aus Dateien**:
+
+- NW Scheduler kann die Einteilungen der Leben-und-Dienst-Zusammenkunft als CSV ausgeben (Spalten `Date, Person, PartType, Assignment, School`, PartType z. B. `Chairman`, `OpeningPrayer`, `TreasuresTalk`, `Gems`, `BibleReading`, `Apply1–4`, `Living1–3`, `CBS`, `CBSReader`). Andere Tools lesen genau dieses Format ein.
+- Außerdem gibt es Sicherungspakete, die z. B. CongPlan inklusive Zuteilungsverlauf übernimmt.
+
+Ein Upgrade wäre also: *Ablage › Aus NW Scheduler importieren …* liest die CSV, ordnet `PartType` den Mawari-Aufgaben zu, gleicht Namen mit den Personen ab und ergänzt nur neue Einträge. Vorher an einer echten Exportdatei prüfen, wie Namen und Partner darin stehen.
+
+© Zielke Studio
diff --git a/Tools/build-dmg.sh b/Tools/build-dmg.sh
new file mode 100644
index 0000000..400191c
--- /dev/null
+++ b/Tools/build-dmg.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+# Baut Mawari signiert und notarisiert als DMG (zum Verschicken und für Updates).
+#
+# Voraussetzungen (einmalig):
+# - Developer-ID-Application-Zertifikat im Login-Schlüsselbund (Team XY4C2RG3L5)
+# - notarytool-Profil. Gleiches Team wie Patchcord, daher dessen Profil als Standard:
+# NOTARY_PROFILE=patchcord-notary
+#
+# Aufruf: bash Tools/build-dmg.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+TEAM_ID="XY4C2RG3L5"
+NOTARY_PROFILE="${NOTARY_PROFILE:-patchcord-notary}"
+VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
+APP_NAME="Mawari"
+BUILD_DIR="build"
+ARCHIVE_PATH="$BUILD_DIR/Mawari.xcarchive"
+DMG_STAGING="$BUILD_DIR/dmg-staging"
+DMG_PATH="$BUILD_DIR/${APP_NAME}-${VERSION}.dmg"
+SIGN_ID="Developer ID Application: Samuel Zielke ($TEAM_ID)"
+
+echo "==> Version $VERSION, Team $TEAM_ID, Notarisierung über Profil $NOTARY_PROFILE"
+xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \
+ || { echo "Fehler: notarytool-Profil $NOTARY_PROFILE fehlt (xcrun notarytool store-credentials …)." >&2; exit 1; }
+
+rm -rf "$ARCHIVE_PATH" "$DMG_STAGING" "$DMG_PATH"
+mkdir -p "$DMG_STAGING"
+
+command -v xcodegen >/dev/null && xcodegen >/dev/null
+
+echo "==> Archiviere (Release, Universal)"
+xcodebuild -project Mawari.xcodeproj -scheme Mawari -configuration Release \
+ -archivePath "$ARCHIVE_PATH" -derivedDataPath "$BUILD_DIR/dd-release" \
+ CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" DEVELOPMENT_TEAM="$TEAM_ID" \
+ archive -quiet
+
+APP_PATH="$ARCHIVE_PATH/Products/Applications/${APP_NAME}.app"
+[ -d "$APP_PATH" ] || { echo "Fehler: $APP_PATH nicht gefunden." >&2; exit 1; }
+
+# Sparkles Hilfsprogramme signiert Xcode nur ad-hoc – die Notarisierung lehnt das ab.
+# Von innen nach außen nachsignieren (siehe drop.look/Tools/build-beta-dmg.sh).
+echo "==> Sparkle nachsignieren"
+resign() { [ -e "$1" ] || return 0; codesign --force --timestamp --options runtime --sign "$SIGN_ID" "$1"; }
+SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework"
+if [ -d "$SPARKLE" ]; then
+ V="$SPARKLE/Versions/Current"
+ resign "$V/XPCServices/Downloader.xpc"
+ resign "$V/XPCServices/Installer.xpc"
+ resign "$V/Autoupdate"
+ resign "$V/Updater.app"
+ resign "$SPARKLE"
+fi
+echo "==> App signieren"
+codesign --force --timestamp --options runtime --entitlements App/Mawari.entitlements --sign "$SIGN_ID" "$APP_PATH"
+codesign --verify --deep --strict --verbose=2 "$APP_PATH"
+lipo -archs "$APP_PATH/Contents/MacOS/$APP_NAME"
+
+echo "==> App notarisieren (dauert ein paar Minuten)"
+ZIP_PATH="$BUILD_DIR/${APP_NAME}-notarize.zip"
+rm -f "$ZIP_PATH"
+ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
+xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
+xcrun stapler staple "$APP_PATH"
+spctl --assess --type execute --verbose "$APP_PATH"
+
+echo "==> DMG bauen"
+cp -R "$APP_PATH" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Programme"
+hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH" -quiet
+codesign --force --timestamp --sign "$SIGN_ID" "$DMG_PATH"
+
+echo "==> DMG notarisieren (damit sie auch per Mail verschickt ohne Warnung aufgeht)"
+xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
+xcrun stapler staple "$DMG_PATH"
+spctl --assess --type open --context context:primary-signature --verbose "$DMG_PATH"
+
+echo "==> Fertig: $DMG_PATH"
+echo "Für ein Online-Update danach: bash Tools/make-appcast.sh && bash Tools/publish.sh"
diff --git a/Tools/make-appcast.sh b/Tools/make-appcast.sh
new file mode 100644
index 0000000..a350ad3
--- /dev/null
+++ b/Tools/make-appcast.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Signiert das fertige DMG mit Mawaris Sparkle-Schlüssel (Schlüsselbund, Konto "mawari")
+# und schreibt build/appcast.xml. Danach: bash Tools/publish.sh
+#
+# Aufruf: bash Tools/make-appcast.sh ["Was ist neu – ein Satz"]
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+BASE_URL="https://update.zielke.studio/mawari"
+VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
+BUILD=$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*CURRENT_PROJECT_VERSION: *"?([^"]+)"?/\1/')
+MIN_OS=$(grep -m1 'macOS:' project.yml | sed -E 's/.*macOS: *"?([^"]+)"?/\1/')
+DMG="build/Mawari-${VERSION}.dmg"
+NOTES="${1:-}"
+
+SIGN_UPDATE=$(find build/dd build/dd-release -path "*artifacts/sparkle/Sparkle/bin/sign_update" 2>/dev/null | head -1)
+[ -x "$SIGN_UPDATE" ] || { echo "Fehler: sign_update fehlt – erst einmal bauen." >&2; exit 1; }
+[ -f "$DMG" ] || { echo "Fehler: $DMG fehlt – erst Tools/build-dmg.sh." >&2; exit 1; }
+
+SIG_LINE=$("$SIGN_UPDATE" --account mawari "$DMG")
+ED_SIG=$(echo "$SIG_LINE" | sed -E 's/.*sparkle:edSignature="([^"]+)".*/\1/')
+LENGTH=$(echo "$SIG_LINE" | sed -E 's/.*length="([^"]+)".*/\1/')
+[ -n "$ED_SIG" ] && [ "$ED_SIG" != "$SIG_LINE" ] || { echo "Fehler beim Signieren: $SIG_LINE" >&2; exit 1; }
+
+DESC=""
+[ -n "$NOTES" ] && DESC="${NOTES}
]]>"
+
+cat > build/appcast.xml <
+
+
+ Mawari
+ ${BASE_URL}/appcast.xml
+ Updates für Mawari
+ de
+ -
+ ${VERSION}
+ ${DESC}
+ $(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')
+ ${BUILD}
+ ${VERSION}
+ ${MIN_OS}
+
+
+
+
+XML
+echo "==> build/appcast.xml für Version $VERSION (Build $BUILD) geschrieben"
diff --git a/Tools/make-icon.swift b/Tools/make-icon.swift
new file mode 100644
index 0000000..2c0035c
--- /dev/null
+++ b/Tools/make-icon.swift
@@ -0,0 +1,117 @@
+import Foundation
+import AppKit
+
+// Zeichnet das App-Symbol von Mawari (回り, „reihum“): eine Diensttafel aus Hinoki-Holz
+// mit drei hängenden Namensschildern. Das mittlere ist auf die rote Seite
+// gedreht – „ist dran“. Grund in Tokiwa-Grün.
+//
+// Aufruf: swift Tools/make-icon.swift (schreibt App/Resources/AppIcon.icns
+// und design/icon-1024.png)
+
+func rgb(_ hex: UInt32, _ a: CGFloat = 1) -> CGColor {
+ CGColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: a)
+}
+
+func drawIcon(size: CGFloat) -> CGImage {
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let ctx = CGContext(data: nil, width: Int(size), height: Int(size), bitsPerComponent: 8, bytesPerRow: 0,
+ space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
+ let s = size / 1024
+ let inset = 100 * s
+ let rect = CGRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2)
+ let radius = rect.width * 0.2237
+
+ // Schatten unter der Kachel
+ ctx.saveGState()
+ ctx.setShadow(offset: CGSize(width: 0, height: -10 * s), blur: 24 * s, color: rgb(0x000000, 0.28))
+ ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil))
+ ctx.setFillColor(rgb(0x2F6B4F)); ctx.fillPath()
+ ctx.restoreGState()
+
+ ctx.saveGState()
+ ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil))
+ ctx.clip()
+ let bg = CGGradient(colorsSpace: cs, colors: [rgb(0x1E4A36), rgb(0x2F6B4F), rgb(0x3E8462)] as CFArray, locations: [0, 0.55, 1])!
+ ctx.drawLinearGradient(bg, start: CGPoint(x: 0, y: rect.minY), end: CGPoint(x: 0, y: rect.maxY), options: [])
+
+ // Holztafel
+ let board = CGRect(x: rect.minX + 118 * s, y: rect.minY + 170 * s, width: rect.width - 236 * s, height: rect.height - 330 * s)
+ ctx.saveGState()
+ ctx.setShadow(offset: CGSize(width: 0, height: -12 * s), blur: 20 * s, color: rgb(0x0B1F16, 0.45))
+ ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil))
+ ctx.setFillColor(rgb(0xE6CC9C)); ctx.fillPath()
+ ctx.restoreGState()
+ // Maserung, ganz zart
+ ctx.saveGState()
+ ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil))
+ ctx.clip()
+ ctx.setStrokeColor(rgb(0xC9A86A, 0.35)); ctx.setLineWidth(5 * s)
+ for i in 0..<7 {
+ let y = board.minY + CGFloat(i) * board.height / 6.5 + 18 * s
+ ctx.move(to: CGPoint(x: board.minX, y: y))
+ ctx.addCurve(to: CGPoint(x: board.maxX, y: y + 10 * s),
+ control1: CGPoint(x: board.minX + board.width * 0.35, y: y + 22 * s),
+ control2: CGPoint(x: board.minX + board.width * 0.65, y: y - 18 * s))
+ }
+ ctx.strokePath()
+ ctx.restoreGState()
+
+ // Leiste oben mit drei Haken
+ let rail = CGRect(x: board.minX + 40 * s, y: board.maxY - 96 * s, width: board.width - 80 * s, height: 22 * s)
+ ctx.addPath(CGPath(roundedRect: rail, cornerWidth: 11 * s, cornerHeight: 11 * s, transform: nil))
+ ctx.setFillColor(rgb(0xB89260)); ctx.fillPath()
+
+ // Namensschilder: hoch und schmal, wie in Japan üblich
+ let plateW = 128 * s, plateH = 330 * s, gap = 40 * s
+ let total = plateW * 3 + gap * 2
+ let x0 = board.midX - total / 2
+ let top = rail.minY - 20 * s
+ for i in 0..<3 {
+ let x = x0 + CGFloat(i) * (plateW + gap)
+ let plate = CGRect(x: x, y: top - plateH, width: plateW, height: plateH)
+ let onDuty = i == 1
+ // Haken
+ ctx.setFillColor(rgb(0x6B5135))
+ ctx.fillEllipse(in: CGRect(x: plate.midX - 13 * s, y: rail.midY - 13 * s, width: 26 * s, height: 26 * s))
+ ctx.setStrokeColor(rgb(0x6B5135)); ctx.setLineWidth(6 * s)
+ ctx.move(to: CGPoint(x: plate.midX, y: rail.midY)); ctx.addLine(to: CGPoint(x: plate.midX, y: plate.maxY - 10 * s)); ctx.strokePath()
+ // Schild
+ ctx.saveGState()
+ ctx.setShadow(offset: CGSize(width: 0, height: -6 * s), blur: 10 * s, color: rgb(0x3D2A14, 0.35))
+ ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 18 * s, cornerHeight: 18 * s, transform: nil))
+ ctx.setFillColor(onDuty ? rgb(0xB5452B) : rgb(0xFBF6EC)); ctx.fillPath()
+ ctx.restoreGState()
+ // "Name": drei kurze Zeilen wie Schrift (bewusst ohne Zeichen, die als Symbol gelesen werden könnten)
+ ctx.setStrokeColor(onDuty ? rgb(0xFBF6EC) : rgb(0x2B2A27, 0.85)); ctx.setLineCap(.round); ctx.setLineWidth(16 * s)
+ let lines: [(CGFloat, CGFloat)] = [(0.30, 0.62), (0.45, 0.44), (0.60, 0.54)]
+ for (y, w) in lines {
+ let yy = plate.maxY - plate.height * y
+ ctx.move(to: CGPoint(x: plate.midX - plate.width * w / 2, y: yy))
+ ctx.addLine(to: CGPoint(x: plate.midX + plate.width * w / 2, y: yy))
+ }
+ ctx.strokePath()
+ }
+ ctx.restoreGState()
+ return ctx.makeImage()!
+}
+
+func png(_ img: CGImage, to url: URL) {
+ let rep = NSBitmapImageRep(cgImage: img)
+ try! rep.representation(using: .png, properties: [:])!.write(to: url)
+}
+
+let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+let iconset = root.appendingPathComponent("build/AppIcon.iconset")
+try? FileManager.default.removeItem(at: iconset)
+try! FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
+for base in [16, 32, 128, 256, 512] {
+ png(drawIcon(size: CGFloat(base)), to: iconset.appendingPathComponent("icon_\(base)x\(base).png"))
+ png(drawIcon(size: CGFloat(base * 2)), to: iconset.appendingPathComponent("icon_\(base)x\(base)@2x.png"))
+}
+try? FileManager.default.createDirectory(at: root.appendingPathComponent("design"), withIntermediateDirectories: true)
+png(drawIcon(size: 1024), to: root.appendingPathComponent("design/icon-1024.png"))
+let p = Process()
+p.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
+p.arguments = ["-c", "icns", iconset.path, "-o", root.appendingPathComponent("App/Resources/AppIcon.icns").path]
+try! p.run(); p.waitUntilExit()
+print(p.terminationStatus == 0 ? "AppIcon.icns geschrieben" : "iconutil fehlgeschlagen")
diff --git a/Tools/publish.sh b/Tools/publish.sh
new file mode 100644
index 0000000..eb2e82f
--- /dev/null
+++ b/Tools/publish.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Lädt DMG und Appcast auf den Update-Server. Ab dann bieten alle installierten
+# Mawari-Kopien das Update beim nächsten Start (bzw. binnen eines Tages) an.
+#
+# Einmalig vorher den Ordner auf dem Server anlegen (siehe zielke-apps/README.md):
+# ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari'
+#
+# Aufruf: bash Tools/publish.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+SERVER="${SERVER:-samuelzielke@10.0.5.223}"
+DEST="/var/www/updates/mawari/"
+VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
+DMG="build/Mawari-${VERSION}.dmg"
+
+[ -f "$DMG" ] && [ -f build/appcast.xml ] || { echo "Fehler: erst Tools/build-dmg.sh und Tools/make-appcast.sh." >&2; exit 1; }
+grep -q "Mawari-${VERSION}.dmg" build/appcast.xml || { echo "Fehler: appcast.xml passt nicht zu $VERSION." >&2; exit 1; }
+
+cp "$DMG" build/Mawari-latest.dmg
+# DMG zuerst, Appcast zuletzt – sonst zeigt der Appcast kurz auf eine fehlende Datei.
+rsync -a --chmod=F644 "$DMG" build/Mawari-latest.dmg "$SERVER:$DEST"
+rsync -a --chmod=F644 build/appcast.xml "$SERVER:$DEST"
+echo "==> Veröffentlicht: https://update.zielke.studio/mawari/appcast.xml (Version $VERSION)"
+curl -fsS "https://update.zielke.studio/mawari/appcast.xml" | grep -o "[^<]*" || echo "Hinweis: Appcast noch nicht abrufbar (Proxy/DNS prüfen)."
diff --git a/design/README.md b/design/README.md
new file mode 100644
index 0000000..ca26bb4
--- /dev/null
+++ b/design/README.md
@@ -0,0 +1,29 @@
+# Design
+
+Leitbild: die hölzerne Diensttafel (*tōban-hyō*), auf der reihum (*mawari*) eingetragen wird, wer dran ist. Ruhig, hell, gut lesbar – die Tabelle ist das Produkt.
+
+## Farben
+
+Definiert in `App/Sources/Theme.swift`, jede Farbe mit heller und dunkler Fassung.
+
+| Name | Hell | Dunkel | Verwendung |
+|---|---|---|---|
+| Tokiwa (常盤, immergrün) | `#2F6B4F` | `#6DBB93` | Markenfarbe, Bereich „Diener (Ernannte)“ |
+| Kuri (栗, Kastanie) | `#7A5230` | `#D0A07A` | Bereich „Schulung“ |
+| Kohaku (琥珀, Bernstein) | `#B45309` | `#F0A35A` | „nie“ – Person hatte die Aufgabe noch nie |
+| Bengara (弁柄, Eisenrot) | `#B5452B` | `#E8775D` | Warnungen, Fehler |
+| Line / Muted / Faint | – | – | Linien, Nebentext, „–“ (nicht freigegeben) |
+
+Zu jeder Bereichsfarbe gibt es eine helle Fläche (`tokiwaSoft`, `kuriSoft`, `kohakuSoft`) für Kopfzeilen und Hervorhebungen.
+
+## Symbol
+
+`icon-1024.png` ist die Vorlage. Das Symbol wird nicht von Hand gezeichnet, sondern erzeugt:
+
+```bash
+swift Tools/make-icon.swift # schreibt design/icon-1024.png und App/Resources/AppIcon.icns
+```
+
+## Schrift und Texte
+
+SF Pro (System), Zahlen und Daten in Monospaced-Ziffern. Texte auf Deutsch, kurz, Du-Form. Datum in der Übersicht als `TT.MM.JJ`, dazu „vor n Wochen“.
diff --git a/design/icon-1024.png b/design/icon-1024.png
new file mode 100644
index 0000000..f21d192
Binary files /dev/null and b/design/icon-1024.png differ
diff --git a/docs/datenformat.md b/docs/datenformat.md
new file mode 100644
index 0000000..1cca02d
--- /dev/null
+++ b/docs/datenformat.md
@@ -0,0 +1,48 @@
+# Datenformat
+
+Mawari speichert alles in einer JSON-Datei:
+
+```
+~/Library/Application Support/Mawari/
+├── daten.json aktueller Stand, wird nach jeder Änderung (300 ms verzögert) atomar geschrieben
+└── Sicherungen/
+ ├── daten-YYYY-MM-DD.json eine Kopie pro Tag beim Start, die letzten 60 bleiben
+ └── vor-import-.json Stand vor jedem Import
+```
+
+Das Format ist identisch mit `leben-und-dienst.json` aus dem früheren HTML-Tool. Dateien lassen sich in beide Richtungen austauschen (*Ablage › Sicherung speichern / laden*).
+
+## Aufbau
+
+```json
+{
+ "app": "leben-und-dienst-uebersicht",
+ "version": 1,
+ "updated": "2026-10-22T11:00:00Z",
+ "tasks": [
+ { "id": "vorsitz", "name": "Vorsitz", "group": "diener", "withPartner": false },
+ { "id": "gespraeche", "name": "Gespräche beginnen", "group": "schulung", "withPartner": true },
+ { "id": "partner", "name": "Partner", "group": "schulung", "withPartner": false, "isPartner": true }
+ ],
+ "persons": [
+ { "id": "p1a2b3c4d", "name": "Max Muster", "gender": "", "note": "", "tasks": ["gebet", "bibellesung"] }
+ ],
+ "entries": [
+ { "id": "e5f6a7b8c", "date": "2026-10-16", "task": "gespraeche", "person": "p1a2b3c4d", "partner": "p9z8y7x6w", "note": "" }
+ ]
+}
+```
+
+| Feld | Bedeutung |
+|---|---|
+| `tasks[].group` | `diener` (Aufgaben für Ernannte) oder `schulung` |
+| `tasks[].withPartner` | Eintrag hat ein Partner-Feld |
+| `tasks[].isPartner` | Sonderspalte „Partner“: zeigt, wann jemand zuletzt **als Partner** dabei war (wird aus `entries[].partner` berechnet, nicht direkt eingetragen) |
+| `persons[].tasks` | Aufgaben, für die die Person freigegeben ist. Nicht freigegeben = „–“ in der Übersicht |
+| `entries[].date` | `YYYY-MM-DD`; Datum in der Zukunft wird als „geplant“ angezeigt |
+
+## Toleranz beim Laden
+
+Fehlende Felder werden mit Standardwerten ergänzt. Fehlt `tasks` oder ist die Liste leer, gelten die 15 Standardaufgaben aus `Database.defaultTasks` (`App/Sources/Model.swift`). Pflicht sind nur `persons` und `entries`.
+
+Wird eine Person gelöscht, verschwinden ihre Einträge; wo sie Partner war, wird das Feld geleert.
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..af99af1
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,78 @@
+name: Mawari
+options:
+ bundleIdPrefix: studio.zielke
+ deploymentTarget:
+ macOS: "14.0"
+ developmentLanguage: de
+ createIntermediateGroups: true
+
+settings:
+ base:
+ SWIFT_VERSION: "6.0"
+ MARKETING_VERSION: "1.0.0"
+ CURRENT_PROJECT_VERSION: "1"
+ MACOSX_DEPLOYMENT_TARGET: "14.0"
+ ARCHS: "arm64 x86_64"
+ DEAD_CODE_STRIPPING: YES
+ configs:
+ Debug:
+ ONLY_ACTIVE_ARCH: YES
+ Release:
+ ONLY_ACTIVE_ARCH: NO
+
+packages:
+ # Selbstaktualisierung, gleiche Fassung wie drop.look und drop.two.
+ Sparkle:
+ url: https://github.com/sparkle-project/Sparkle
+ exactVersion: 2.10.0
+
+targets:
+ Mawari:
+ type: application
+ platform: macOS
+ sources:
+ - path: App/Sources
+ # Das Symbol wird aus Tools/make-icon.swift erzeugt, nicht von Hand gezeichnet.
+ - path: App/Resources/AppIcon.icns
+ buildPhase: resources
+ dependencies:
+ - package: Sparkle
+ info:
+ # xcodegen ERZEUGT diese Datei aus den Angaben hier.
+ path: App/Info.plist
+ properties:
+ CFBundleName: Mawari
+ CFBundleDisplayName: Mawari
+ CFBundleIconFile: AppIcon
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
+ LSApplicationCategoryType: public.app-category.productivity
+ NSHumanReadableCopyright: © Zielke Studio
+ # --- Selbstaktualisierung (Sparkle) ---
+ SUFeedURL: https://update.zielke.studio/mawari/appcast.xml
+ # Öffentlicher EdDSA-Schlüssel, eigenes Paar für Mawari (Konto "mawari").
+ # Der private liegt im Schlüsselbund dieses Rechners – sichern!
+ SUPublicEDKey: MMz3ZcmIvAmxRd7hXMGFYq0E4wrwV8pMhUxnDcyc48I=
+ # Beim Start nach Updates suchen (Papa soll sich um nichts kümmern müssen).
+ SUEnableAutomaticChecks: YES
+ SUScheduledCheckInterval: 86400
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.mawari
+ PRODUCT_NAME: Mawari
+ CODE_SIGN_STYLE: Manual
+ CODE_SIGN_IDENTITY: "Developer ID Application"
+ DEVELOPMENT_TEAM: XY4C2RG3L5
+ ENABLE_HARDENED_RUNTIME: YES
+ CODE_SIGN_ENTITLEMENTS: App/Mawari.entitlements
+
+schemes:
+ Mawari:
+ build:
+ targets:
+ Mawari: all
+ run:
+ config: Debug
+ archive:
+ config: Release