feat: init

This commit is contained in:
hh
2026-08-08 16:30:22 +02:00
commit 8a609da778
59 changed files with 10053 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
export interface DragPayload {
id: string;
kind: string;
}
export interface DropZone {
target: string;
after: boolean;
}
let dragging = $state<DragPayload | null>(null);
let zone = $state<DropZone | null>(null);
export function isDragging(id: string): boolean {
return dragging?.id === id;
}
export function dragPayload(): DragPayload | null {
return dragging;
}
export function dropZone(): DropZone | null {
return zone;
}
const MOVE_THRESHOLD = 6;
const HOLD_MS = 350;
interface DraggableOptions {
payload: () => DragPayload;
ondrop: (target: string, after: boolean) => void;
enabled?: () => boolean;
}
export function draggable(node: HTMLElement, options: DraggableOptions) {
let current = options;
let startX = 0;
let startY = 0;
let holdTimer: number | null = null;
let active = false;
let armed = false;
let pointerId: number | null = null;
let frame: number | null = null;
let lastX = 0;
let lastY = 0;
const swallowClick = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
};
const clearHold = (): void => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
};
const paint = (): void => {
frame = null;
node.style.transform = `translate3d(${lastX - startX}px, ${lastY - startY}px, 0)`;
};
const begin = (): void => {
if (active) return;
active = true;
dragging = current.payload();
if (pointerId !== null) node.setPointerCapture(pointerId);
node.style.touchAction = "none";
node.style.willChange = "transform";
node.classList.add("bcal-dragging");
};
const finish = (commit: boolean): void => {
clearHold();
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
if (pointerId !== null && node.hasPointerCapture(pointerId)) {
node.releasePointerCapture(pointerId);
}
const wasActive = active;
if (wasActive) {
node.addEventListener("click", swallowClick, { capture: true, once: true });
node.style.transition = "transform 160ms ease-out";
node.style.transform = "";
node.classList.remove("bcal-dragging");
window.setTimeout(() => {
node.style.transition = "";
node.style.willChange = "";
}, 180);
}
node.style.touchAction = "";
const landed = zone;
active = false;
armed = false;
pointerId = null;
dragging = null;
zone = null;
if (commit && wasActive && landed) {
current.ondrop(landed.target, landed.after);
}
};
const onpointerdown = (event: PointerEvent): void => {
if (event.button !== 0) return;
if (current.enabled && !current.enabled()) return;
if ((event.target as HTMLElement).closest("[data-nodrag], a")) return;
armed = true;
pointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
lastX = startX;
lastY = startY;
if (event.pointerType === "touch") {
holdTimer = window.setTimeout(() => {
holdTimer = null;
if (armed) begin();
}, HOLD_MS);
}
};
const onpointermove = (event: PointerEvent): void => {
if (!armed || event.pointerId !== pointerId) return;
if (!active) {
const moved =
Math.abs(event.clientX - startX) > MOVE_THRESHOLD ||
Math.abs(event.clientY - startY) > MOVE_THRESHOLD;
if (!moved) return;
if (event.pointerType === "touch") {
armed = false;
clearHold();
return;
}
begin();
}
event.preventDefault();
lastX = event.clientX;
lastY = event.clientY;
if (frame === null) frame = requestAnimationFrame(paint);
const previous = node.style.pointerEvents;
node.style.pointerEvents = "none";
const under = document.elementFromPoint(event.clientX, event.clientY);
node.style.pointerEvents = previous;
const target = under?.closest<HTMLElement>("[data-drop]");
if (!target) {
zone = null;
return;
}
const box = target.getBoundingClientRect();
zone = {
target: target.dataset.drop as string,
after: event.clientY > box.top + box.height / 2,
};
};
const onpointerup = (event: PointerEvent): void => {
if (event.pointerId !== pointerId) return;
finish(true);
};
const onpointercancel = (event: PointerEvent): void => {
if (event.pointerId !== pointerId) return;
finish(false);
};
node.addEventListener("pointerdown", onpointerdown);
node.addEventListener("pointermove", onpointermove);
node.addEventListener("pointerup", onpointerup);
node.addEventListener("pointercancel", onpointercancel);
return {
update(next: DraggableOptions) {
current = next;
},
destroy() {
clearHold();
if (frame !== null) cancelAnimationFrame(frame);
node.removeEventListener("pointerdown", onpointerdown);
node.removeEventListener("pointermove", onpointermove);
node.removeEventListener("pointerup", onpointerup);
node.removeEventListener("pointercancel", onpointercancel);
},
};
}
+57
View File
@@ -0,0 +1,57 @@
const monthDay = new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "short",
});
export function localKey(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
export function today(): string {
return localKey(new Date());
}
export function fromKey(key: string): Date {
const [year, month, day] = key.split("-").map(Number);
return new Date(year, month - 1, day);
}
export function shiftDays(key: string, days: number): string {
const date = fromKey(key);
date.setDate(date.getDate() + days);
return localKey(date);
}
export function daysFromToday(key: string): number {
const target = fromKey(key);
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.round((target.getTime() - now.getTime()) / 86_400_000);
}
export function dueLabel(key: string | null): string {
if (!key) return "";
const diff = daysFromToday(key);
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff === -1) return "yesterday";
return monthDay.format(fromKey(key));
}
export type DueTone = "overdue" | "soon" | "normal" | "none";
export function dueTone(key: string | null): DueTone {
if (!key) return "none";
const diff = daysFromToday(key);
if (diff < 0) return "overdue";
if (diff <= 1) return "soon";
return "normal";
}
export function nextFriday(): string {
const date = new Date();
date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7));
return localKey(date);
}
+9
View File
@@ -0,0 +1,9 @@
const LABEL_HUES = [200, 155, 75, 300, 25, 260];
export function labelHue(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i += 1) {
hash = (hash * 31 + name.charCodeAt(i)) % 997;
}
return LABEL_HUES[hash % LABEL_HUES.length];
}
+12
View File
@@ -0,0 +1,12 @@
export function laneKey(path: string, project: string): string {
return `${encodeURIComponent(path)} ${encodeURIComponent(project)}`;
}
export function parseLaneKey(key: string): { path: string; project: string } | null {
const at = key.indexOf(" ");
if (at === -1) return null;
return {
path: decodeURIComponent(key.slice(0, at)),
project: decodeURIComponent(key.slice(at + 1)),
};
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+36
View File
@@ -0,0 +1,36 @@
import Circle from "@lucide/svelte/icons/circle";
import CircleCheck from "@lucide/svelte/icons/circle-check";
import CircleDot from "@lucide/svelte/icons/circle-dot";
import CircleSlash from "@lucide/svelte/icons/circle-slash";
import type { Component } from "svelte";
import type { Priority, Status } from "../model/types";
export interface StatusMeta {
icon: Component;
color: string;
label: string;
}
export const STATUS_META: Record<Status, StatusMeta> = {
" ": { icon: Circle, color: "var(--muted-foreground)", label: "Todo" },
"/": { icon: CircleDot, color: "var(--status-reply)", label: "In progress" },
x: { icon: CircleCheck, color: "var(--status-done)", label: "Done" },
"-": { icon: CircleSlash, color: "var(--status-skip)", label: "Cancelled" },
};
export const PRIORITY_LABEL: Record<Priority, string> = {
0: "Lowest",
1: "Low",
2: "No priority",
3: "Medium",
4: "High",
5: "Highest",
};
export const PRIORITY_ORDER: Priority[] = [5, 4, 3, 2, 1, 0];
export function isHot(priority: Priority): boolean {
return priority === 5;
}
export const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];