feat(home): concept Hem with Att göra + Fortsätt (UI migration PR 11) (#1132)
* feat(home): concept scene 14: greeting + Att gora/Fortsatt panes Hem becomes the founder-approved two-panel layout: serif time-of-day greeting with date and company, the Att gora worklist restyled to the concept pane (eyebrow header, h-rows with count chips, hover chevrons) and a new Fortsatt pane listing in-progress work derived purely from draft state (lib/worklist/resume: journal drafts, invoice drafts/unsent, mid-lifecycle salary runs; deadline boost, cap 3, tested). A completed flow can never render as a resume row by construction: only draft-state rows are fetched. KPI tiles, revenue/expense cards and the deadline/tax widgets leave the page per dev_docs/last_session_resume.md section 8, which also prunes their fetches (journal-line YTD aggregation, unpaid totals, deadlines): the page got faster. Banners, checklist, build-assistant hero and the Skatteverket nudge survive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): serif pane titles for Att gora and Fortsatt Founder feedback: the uppercase eyebrow headers read as a stray font. Both pane titles are now the Hedvig display serif (text-lg) over the hairline, matching the page's heading language; the band headers inside Att gora keep their small uppercase form as grouping devices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): Geist pane titles for Att gora and Fortsatt Founder call: the pane titles use the body sans (14px medium), not the display serif. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): Geist section headers + drop stale-transactions chip Founder feedback: pane/section headers (Att gora, Fortsatt, the reports groups Lopande/Bokslut/Skatt & moms etc) render in Geist sentence case instead of uppercase eyebrows or serif. The global h1-h3 display-font rule moves into @layer base so utility classes like font-sans can actually override it (unlayered element rules beat Tailwind's layered utilities: this was silently eating the override). Also removes the 'N aldre an 14 dagar' chip from the Bokfora transaktioner row and its stale-count plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): continuous nav crossfade + floating slide-over entrance The rail/full nav states now stay mounted and crossfade past each other (the inactive layer absolute, faded, nudged sideways, inert) while the aside width animates: the switch reads as one continuous motion instead of a DOM swap. The detail slide-over floats in from the right edge (slide-in-from-right-full, 300ms decelerating curve) per the concept, with a quicker ease-in exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make transitions and enter/exit animations actually run Two silent app-wide animation killers found while chasing 'the nav still is not smooth': 1. The codebase uses the shadcn animate-in/out vocabulary everywhere but no animate plugin was ever installed: Tailwind v4 silently dropped every such class, so popovers, dialogs, menus and the slide-over all appeared instantly. globals.css now defines the exact subset in use (accEnter/accExit keyframes + var-driven utilities), plugin-free, composing with duration/ease via --tw-duration/--tw-ease and collapsing under prefers-reduced-motion. Dialog drops its bracket-variant slide classes (zoom+fade carries the entrance). 2. The scrollbar auto-hide block's universal '* { transition: scrollbar-color ... }' was unlayered, and unlayered rules beat Tailwind's layered transition-* utilities regardless of specificity: every width/margin/color transition in the app was dead. The rule now lives in @layer base. Verified: the aside animates 248->64 over 300ms and the slide-over runs accEnter at 0.3s with the decelerating curve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): finish the rr-mask session-replay masking sweep Main's #1105 switched one amount cell from the no-op sensitive-field class to rr-mask (rrweb's built-in text-masking class). The reskinned tables introduced more sensitive-field cells; all 12 occurrences now use rr-mask so financial amounts are masked in session replays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4352862845
commit
be9d630347
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mergeResumeItems,
|
||||
isSalaryRunLate,
|
||||
RESUME_MAX_ROWS,
|
||||
type ResumeItem,
|
||||
} from '../resume'
|
||||
|
||||
function item(overrides: Partial<ResumeItem>): ResumeItem {
|
||||
return {
|
||||
kind: 'journal_draft',
|
||||
ref: `journal:${Math.abs(overrides.updated_at?.length ?? 1)}`,
|
||||
href: '/bookkeeping/x',
|
||||
context: null,
|
||||
updated_at: '2026-07-01T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergeResumeItems', () => {
|
||||
it('orders by updated_at desc', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
|
||||
item({ ref: 'b', updated_at: '2026-07-03T10:00:00Z' }),
|
||||
item({ ref: 'c', updated_at: '2026-07-02T10:00:00Z' }),
|
||||
])
|
||||
expect(merged.map((i) => i.ref)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('boosts late items above newer non-late items', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'fresh', updated_at: '2026-07-20T10:00:00Z' }),
|
||||
item({ ref: 'late-old', late: true, updated_at: '2026-05-01T10:00:00Z' }),
|
||||
])
|
||||
expect(merged[0].ref).toBe('late-old')
|
||||
})
|
||||
|
||||
it('caps at RESUME_MAX_ROWS', () => {
|
||||
const many = Array.from({ length: 7 }, (_, i) =>
|
||||
item({ ref: `r${i}`, updated_at: `2026-07-0${i + 1}T10:00:00Z` }),
|
||||
)
|
||||
expect(mergeResumeItems(many)).toHaveLength(RESUME_MAX_ROWS)
|
||||
})
|
||||
|
||||
it('keeps late-first ordering stable within groups', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'late-new', late: true, updated_at: '2026-07-10T10:00:00Z' }),
|
||||
item({ ref: 'late-old', late: true, updated_at: '2026-06-10T10:00:00Z' }),
|
||||
item({ ref: 'plain', updated_at: '2026-07-22T10:00:00Z' }),
|
||||
])
|
||||
expect(merged.map((i) => i.ref)).toEqual(['late-new', 'late-old', 'plain'])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [
|
||||
item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
|
||||
item({ ref: 'b', updated_at: '2026-07-02T10:00:00Z' }),
|
||||
]
|
||||
const before = [...input]
|
||||
mergeResumeItems(input)
|
||||
expect(input).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSalaryRunLate', () => {
|
||||
const now = new Date('2026-07-23T12:00:00Z')
|
||||
|
||||
it('flags a run two months past its period', () => {
|
||||
expect(isSalaryRunLate(2026, 5, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag last month', () => {
|
||||
expect(isSalaryRunLate(2026, 6, now)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag the current period', () => {
|
||||
expect(isSalaryRunLate(2026, 7, now)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles year boundaries', () => {
|
||||
expect(isSalaryRunLate(2025, 12, now)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Resume list ("Fortsätt") for the homepage: in-progress work derived purely
|
||||
* from draft/mid-lifecycle state (dev_docs/last_session_resume.md §6).
|
||||
* No event log, no presence table: a completed flow can never render here
|
||||
* because only draft-state rows are ever fetched (the reliability invariant).
|
||||
*
|
||||
* Lives in lib/worklist (owns pending-work predicates) but is deliberately
|
||||
* NOT part of WorklistCounts.total: Att göra = obligations the system
|
||||
* imposes; Fortsätt = work the user started and left. An item renders in
|
||||
* exactly one surface.
|
||||
*/
|
||||
|
||||
export type ResumeItemKind =
|
||||
| 'journal_draft'
|
||||
| 'invoice_draft'
|
||||
| 'invoice_unsent'
|
||||
| 'salary_run'
|
||||
|
||||
export type ResumeSalaryStatus = 'draft' | 'review' | 'approved' | 'paid'
|
||||
|
||||
export interface ResumeItem {
|
||||
kind: ResumeItemKind
|
||||
/** Stable reference, e.g. 'invoice:<uuid>'. */
|
||||
ref: string
|
||||
href: string
|
||||
/** Free-text context: verifikat description, customer name, or period. */
|
||||
context: string | null
|
||||
/** Invoice number (unsent invoices). */
|
||||
number?: string | null
|
||||
amount?: number | null
|
||||
currency?: string | null
|
||||
salaryStatus?: ResumeSalaryStatus
|
||||
/** Deadline boost: unsent invoices and stale salary runs outrank newer
|
||||
* trivial drafts (Iqbal & Horvitz: deadline-tied tasks matter more). */
|
||||
late?: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const RESUME_MAX_ROWS = 3
|
||||
|
||||
/** A salary run whose period lies >= this many months back is "late". */
|
||||
const SALARY_LATE_MONTHS = 2
|
||||
|
||||
export function isSalaryRunLate(
|
||||
periodYear: number,
|
||||
periodMonth: number,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const monthsBehind =
|
||||
(now.getFullYear() - periodYear) * 12 + (now.getMonth() + 1 - periodMonth)
|
||||
return monthsBehind >= SALARY_LATE_MONTHS
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure ordering + cap: late items first (deadline boost), then most recently
|
||||
* touched. Deterministic and explainable: no scoring, no ML.
|
||||
*/
|
||||
export function mergeResumeItems(items: ResumeItem[]): ResumeItem[] {
|
||||
return [...items]
|
||||
.sort((a, b) => {
|
||||
if (Boolean(a.late) !== Boolean(b.late)) return a.late ? -1 : 1
|
||||
return b.updated_at.localeCompare(a.updated_at)
|
||||
})
|
||||
.slice(0, RESUME_MAX_ROWS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch resume candidates for a company. Every query soft-fails to empty:
|
||||
* the worst case is a missing pane, never a broken homepage.
|
||||
*/
|
||||
export async function listResumeItems(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<ResumeItem[]> {
|
||||
const [journalRes, invoiceRes, salaryRes] = await Promise.all([
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, description, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, total, currency, updated_at, customer:customers(name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
supabase
|
||||
.from('salary_runs')
|
||||
.select('id, period_year, period_month, status, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
// Enumerated on purpose: `!= 'booked'` would resurface 'corrected'.
|
||||
.in('status', ['draft', 'review', 'approved', 'paid'])
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
])
|
||||
|
||||
const items: ResumeItem[] = []
|
||||
|
||||
for (const row of (journalRes.data ?? []) as Array<{
|
||||
id: string
|
||||
description: string | null
|
||||
updated_at: string
|
||||
}>) {
|
||||
items.push({
|
||||
kind: 'journal_draft',
|
||||
ref: `journal:${row.id}`,
|
||||
href: `/bookkeeping/${row.id}`,
|
||||
context: row.description,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of (invoiceRes.data ?? []) as Array<{
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
total: number | null
|
||||
currency: string | null
|
||||
updated_at: string
|
||||
customer: { name: string } | { name: string }[] | null
|
||||
}>) {
|
||||
const customer = Array.isArray(row.customer) ? row.customer[0] : row.customer
|
||||
const unsent = !!row.invoice_number
|
||||
items.push({
|
||||
kind: unsent ? 'invoice_unsent' : 'invoice_draft',
|
||||
ref: `invoice:${row.id}`,
|
||||
href: `/invoices/${row.id}`,
|
||||
context: customer?.name ?? null,
|
||||
number: row.invoice_number,
|
||||
amount: row.total,
|
||||
currency: row.currency,
|
||||
late: unsent,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of (salaryRes.data ?? []) as Array<{
|
||||
id: string
|
||||
period_year: number
|
||||
period_month: number
|
||||
status: ResumeSalaryStatus
|
||||
updated_at: string
|
||||
}>) {
|
||||
items.push({
|
||||
kind: 'salary_run',
|
||||
ref: `salary:${row.id}`,
|
||||
href: `/salary/runs/${row.id}`,
|
||||
context: `${row.period_year}-${String(row.period_month).padStart(2, '0')}`,
|
||||
salaryStatus: row.status,
|
||||
late: isSalaryRunLate(row.period_year, row.period_month, now),
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
return mergeResumeItems(items)
|
||||
}
|
||||
Reference in New Issue
Block a user