Files
accounted/lib/calendar/ics-generator.ts
T
Jakob Wennberg 0aecf5b41c Refactor to generic ERP base
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:39:13 +01:00

219 lines
5.9 KiB
TypeScript

/**
* ICS calendar feed generator
* Generates .ics files for Apple Calendar / Google Calendar sync
*/
import { createEvents, type EventAttributes, type DateArray } from 'ics'
import type { Deadline, Invoice } from '@/types'
export interface FeedOptions {
includeTaxDeadlines: boolean
includeInvoices: boolean
}
export interface CalendarData {
deadlines: Deadline[]
invoices: Invoice[]
}
/**
* Generate a stable UID for calendar events
* This ensures updates to events are recognized by calendar apps
*/
function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string {
return `${type}-${id}@${domain}`
}
/**
* Convert date string (YYYY-MM-DD) to ICS DateArray
*/
function dateToArray(dateStr: string): DateArray {
const [year, month, day] = dateStr.split('-').map(Number)
return [year, month, day]
}
/**
* Convert date and optional time to ICS DateArray with hours/minutes
*/
function dateTimeToArray(dateStr: string, timeStr?: string | null): DateArray {
const [year, month, day] = dateStr.split('-').map(Number)
if (timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number)
return [year, month, day, hours, minutes]
}
return [year, month, day, 9, 0] // Default to 9:00 AM
}
/**
* Create alarm arrays for an event (VALARM)
*/
function createAlarms(daysBefore: number[]): EventAttributes['alarms'] {
return daysBefore.map((days) => ({
action: 'display' as const,
trigger: { days, before: true },
description: 'Påminnelse',
}))
}
/**
* Generate deadline events
*/
function generateDeadlineEvents(deadlines: Deadline[]): EventAttributes[] {
return deadlines.map((deadline) => {
const event: EventAttributes = {
uid: getEventUID('deadline', deadline.id),
title: deadline.title,
start: dateTimeToArray(deadline.due_date, deadline.due_time),
duration: { hours: 1 },
description: formatDeadlineDescription(deadline),
categories: ['Deadline', deadline.deadline_type === 'tax' ? 'Skatt' : deadline.deadline_type],
status: deadline.is_completed ? 'CONFIRMED' : 'TENTATIVE',
alarms: createAlarms(deadline.reminder_offsets || [7, 1]),
}
return event
})
}
/**
* Format deadline description for calendar
*/
function formatDeadlineDescription(deadline: Deadline): string {
const lines: string[] = []
if (deadline.tax_deadline_type) {
lines.push(`Typ: ${getSwedishTaxTypeLabel(deadline.tax_deadline_type)}`)
}
if (deadline.tax_period) {
lines.push(`Period: ${deadline.tax_period}`)
}
if (deadline.notes) {
lines.push(`\nNoteringar: ${deadline.notes}`)
}
if (deadline.status) {
lines.push(`\nStatus: ${getSwedishStatusLabel(deadline.status)}`)
}
return lines.join('\n')
}
/**
* Generate invoice events
*/
function generateInvoiceEvents(invoices: Invoice[]): EventAttributes[] {
return invoices.map((invoice) => {
const customerName = invoice.customer?.name || 'Okänd kund'
const event: EventAttributes = {
uid: getEventUID('invoice', invoice.id),
title: `Faktura #${invoice.invoice_number} - ${customerName}`,
start: dateToArray(invoice.due_date),
duration: { days: 1 },
description: formatInvoiceDescription(invoice),
categories: ['Faktura'],
status: invoice.status === 'paid' ? 'CONFIRMED' : 'TENTATIVE',
alarms: createAlarms([3, 1]),
}
return event
})
}
/**
* Format invoice description for calendar
*/
function formatInvoiceDescription(invoice: Invoice): string {
const lines: string[] = []
lines.push(`Belopp: ${invoice.total.toLocaleString('sv-SE')} ${invoice.currency}`)
lines.push(`Status: ${getSwedishInvoiceStatusLabel(invoice.status)}`)
if (invoice.customer?.name) {
lines.push(`Kund: ${invoice.customer.name}`)
}
return lines.join('\n')
}
/**
* Generate complete calendar feed
*/
export function generateCalendarFeed(
data: CalendarData,
options: FeedOptions
): Promise<string> {
const events: EventAttributes[] = []
if (options.includeTaxDeadlines) {
const taxDeadlines = data.deadlines.filter((d) => d.deadline_type === 'tax')
events.push(...generateDeadlineEvents(taxDeadlines))
// Include non-tax deadlines too
const otherDeadlines = data.deadlines.filter((d) => d.deadline_type !== 'tax')
events.push(...generateDeadlineEvents(otherDeadlines))
}
if (options.includeInvoices) {
events.push(...generateInvoiceEvents(data.invoices))
}
return new Promise((resolve, reject) => {
createEvents(events, (error, value) => {
if (error) {
reject(error)
} else {
resolve(value)
}
})
})
}
/**
* Swedish labels for tax deadline types
*/
function getSwedishTaxTypeLabel(type: string): string {
const labels: Record<string, string> = {
moms_monthly: 'Momsdeklaration (månad)',
moms_quarterly: 'Momsdeklaration (kvartal)',
moms_yearly: 'Momsdeklaration (år)',
f_skatt: 'F-skatt',
arbetsgivardeklaration: 'Arbetsgivardeklaration',
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
arsredovisning: 'Årsredovisning',
periodisk_sammanstallning: 'Periodisk sammanställning',
bokslut: 'Bokslut',
}
return labels[type] || type
}
/**
* Swedish labels for deadline status
*/
function getSwedishStatusLabel(status: string): string {
const labels: Record<string, string> = {
upcoming: 'Kommande',
action_needed: 'Åtgärd krävs',
in_progress: 'Pågår',
submitted: 'Inskickad',
confirmed: 'Bekräftad',
overdue: 'Försenad',
}
return labels[status] || status
}
/**
* Swedish labels for invoice status
*/
function getSwedishInvoiceStatusLabel(status: string): string {
const labels: Record<string, string> = {
draft: 'Utkast',
sent: 'Skickad',
paid: 'Betald',
overdue: 'Förfallen',
cancelled: 'Makulerad',
credited: 'Krediterad',
}
return labels[status] || status
}