mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
Space sends a card to the done list or back to the inbox instead of ticking it in place. Enter on a card with no document starts one named after it, and q comes back to the board on the same card. :card captures into the inbox without leaving the current view (c anywhere), :due takes fri, tm, +3d and the like, :name! renames a list, done cards older than a week fold away behind z, and the board spells out its keys. The phone dock gains send, done, due and notes doors.
133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
const pad = (n: number) => String(n).padStart(2, '0')
|
|
|
|
export function toDate(seconds: number): Date {
|
|
return new Date(seconds * 1000)
|
|
}
|
|
|
|
/** `2026-09-05` — the diary is organised by day, so this is the primary label. */
|
|
export function formatDay(seconds: number): string {
|
|
const d = toDate(seconds)
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
}
|
|
|
|
export function formatTime(seconds: number): string {
|
|
const d = toDate(seconds)
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|
|
|
|
export function formatStamp(seconds: number): string {
|
|
return `${formatDay(seconds)} ${formatTime(seconds)}`
|
|
}
|
|
|
|
export function formatWeekday(seconds: number): string {
|
|
return toDate(seconds).toLocaleDateString(undefined, { weekday: 'short' }).toLowerCase()
|
|
}
|
|
|
|
export function relative(seconds: number): string {
|
|
const delta = Math.floor(Date.now() / 1000) - seconds
|
|
if (delta < 60) return 'just now'
|
|
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`
|
|
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`
|
|
if (delta < 86400 * 30) return `${Math.floor(delta / 86400)}d ago`
|
|
return formatDay(seconds)
|
|
}
|
|
|
|
export function formatBytes(bytes: number): string {
|
|
const units = ['B', 'K', 'M', 'G']
|
|
let value = bytes
|
|
let unit = 0
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024
|
|
unit++
|
|
}
|
|
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}`
|
|
}
|
|
|
|
/** Parse the `YYYY-MM-DD` argument of `:date` into a Unix timestamp at noon. */
|
|
export function parseDay(input: string): number | null {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim())
|
|
if (!match) return null
|
|
const [, y, m, d] = match
|
|
const date = new Date(Number(y), Number(m) - 1, Number(d), 12, 0, 0)
|
|
return Number.isNaN(date.getTime()) ? null : Math.floor(date.getTime() / 1000)
|
|
}
|
|
|
|
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
|
|
|
|
/**
|
|
* A due date the way it is said rather than written: `today`, `tm` or
|
|
* `tomorrow`, a weekday (`fri` is the next Friday, never today), `+3d`, `+2w`,
|
|
* `10-31` (this year, or next if that has passed) or a full `2026-10-31`.
|
|
* Noon, like `parseDay`, so no timezone shift can tip it into another day.
|
|
*/
|
|
export function parseDue(input: string, now = new Date()): number | null {
|
|
const text = input.trim().toLowerCase()
|
|
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0)
|
|
const at = (d: Date) => Math.floor(d.getTime() / 1000)
|
|
const shifted = (days: number) => at(new Date(day.getFullYear(), day.getMonth(), day.getDate() + days, 12))
|
|
|
|
if (text === 'today' || text === 'td') return at(day)
|
|
if (text === 'tomorrow' || text === 'tm') return shifted(1)
|
|
const weekday = text.length >= 2 ? WEEKDAYS.findIndex((name) => name.startsWith(text)) : -1
|
|
if (weekday >= 0) return shifted(((weekday - day.getDay() + 6) % 7) + 1)
|
|
const offset = /^\+(\d+)([dw])$/.exec(text)
|
|
if (offset) return shifted(Number(offset[1]) * (offset[2] === 'w' ? 7 : 1))
|
|
const monthDay = /^(\d{1,2})-(\d{1,2})$/.exec(text)
|
|
if (monthDay) {
|
|
const [m, d] = [Number(monthDay[1]) - 1, Number(monthDay[2])]
|
|
let date = new Date(day.getFullYear(), m, d, 12)
|
|
if (date.getMonth() !== m) return null
|
|
if (date < day) date = new Date(day.getFullYear() + 1, m, d, 12)
|
|
return at(date)
|
|
}
|
|
return parseDay(text)
|
|
}
|
|
|
|
/**
|
|
* True on phones and tablets, where modal editing would be a burden. Both
|
|
* checks are needed: a touchscreen laptop reports touch points but still has a
|
|
* fine primary pointer, and headless browsers report `hover: none`.
|
|
*/
|
|
export const isTouchDevice =
|
|
typeof window !== 'undefined' &&
|
|
navigator.maxTouchPoints > 0 &&
|
|
window.matchMedia('(pointer: coarse)').matches
|
|
|
|
/** A heading as an anchor id: lowercase, dashes, the same idea as the server's slugs. */
|
|
export function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/ä/g, 'ae')
|
|
.replace(/ö/g, 'oe')
|
|
.replace(/ü/g, 'ue')
|
|
.replace(/ß/g, 'ss')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80)
|
|
}
|
|
|
|
/** How far a reading view is asked to scroll: lines, a half page, or an end. */
|
|
export type ScrollAmount = number | 'top' | 'bottom' | 'halfdown' | 'halfup'
|
|
|
|
/** j/k, gg/G and Ctrl-d/u on a scrolling element, three lines to a step. */
|
|
export function scrollPage(el: HTMLElement, amount: ScrollAmount) {
|
|
const line = parseFloat(getComputedStyle(el).lineHeight) || 24
|
|
const half = el.clientHeight / 2
|
|
switch (amount) {
|
|
case 'top':
|
|
el.scrollTo({ top: 0 })
|
|
break
|
|
case 'bottom':
|
|
el.scrollTo({ top: el.scrollHeight })
|
|
break
|
|
case 'halfdown':
|
|
el.scrollBy({ top: half })
|
|
break
|
|
case 'halfup':
|
|
el.scrollBy({ top: -half })
|
|
break
|
|
default:
|
|
el.scrollBy({ top: amount * line * 3 })
|
|
}
|
|
}
|