Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions frontend/taskdeck-web/src/api/todayApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import http from './http'

export interface CadenceBucket {
hour: number
eventCount: number
}

export interface CadenceApiResponse {
buckets: CadenceBucket[]
firstActionAt: string | null
peakHour: number | null
lastActionAt: string | null
}

export interface StreakDay {
date: string
isSealed: boolean
intensityBucket: number
}

export interface StreakApiResponse {
days: StreakDay[]
currentStreakLength: number
longestStreakLength: number
dayCount: number
}

export interface SealApiResponse {
sealedAt: string
wasAlreadySealed: boolean
}

export interface SealStatusApiResponse {
date: string
isSealed: boolean
sealedAt: string | null
}

export interface TomorrowNoteApiResponse {
id: string
date: string
text: string
updatedAt: string
createdAt: string
}

export const todayApi = {
async getCadence(date: string): Promise<CadenceApiResponse> {
const { data } = await http.get<CadenceApiResponse>(`/today/cadence?date=${encodeURIComponent(date)}`)
return data
},

async getStreak(days = 90): Promise<StreakApiResponse> {
const { data } = await http.get<StreakApiResponse>(`/today/streak?days=${days}`)
return data
},

async sealDay(date: string): Promise<SealApiResponse> {
const { data } = await http.post<SealApiResponse>('/today/seal', { date })
return data
},

async getSealStatus(date: string): Promise<SealStatusApiResponse> {
const { data } = await http.get<SealStatusApiResponse>(`/today/seal?date=${encodeURIComponent(date)}`)
return data
},

async getTomorrowNote(date: string): Promise<TomorrowNoteApiResponse | null> {
const response = await http.get<TomorrowNoteApiResponse>(`/today/tomorrow-note?date=${encodeURIComponent(date)}`)
if (response.status === 204) return null
return response.data
},

async saveTomorrowNote(date: string, text: string): Promise<TomorrowNoteApiResponse> {
const { data } = await http.put<TomorrowNoteApiResponse>('/today/tomorrow-note', { date, text })
return data
},
}
204 changes: 174 additions & 30 deletions frontend/taskdeck-web/src/composables/useTodayDossier.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import { computed, onScopeDispose, ref, type Ref } from 'vue'
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import { useWorkspaceStore } from '../store/workspaceStore'
import { todayApi, type CadenceApiResponse, type StreakApiResponse } from '../api/todayApi'
import { logError } from '../utils/errorReporting'
import type { TodaySummary } from '../types/workspace'

/**
* useTodayDossier — composable that returns the data shape expected by
* `PaperTodayView` and its 9 sub-sections. Wherever the live workspace
* `todaySummary` already provides a value (overdue cards, captures, etc.)
* we surface it; everything else is mocked behind clearly-marked stubs so
* the surface ships now and follow-up backend issues (#1015–#1018) can wire
* real data later.
*
* Stubs are deterministic given the same `now` so tests can exercise the
* shape without flake. Components must continue to work with both stub
* and real data — never branch on whether a section is mocked.
*/

export type DossierLedgerWho = 'you' | 'haiku' | 'system'
export type DossierLedgerTone = 'ember' | 'applied' | 'active' | 'passive' | 'mute'

Expand Down Expand Up @@ -53,9 +42,7 @@ export interface DossierCarryOverCard {

export interface DossierStatCard {
id: 'cards-moved' | 'proposals-applied' | 'captures-triaged' | 'longest-focus' | 'overdue'
/** Numeric (or formatted) value used for the big italic-serif number. */
value: number | string
/** Whether `value` is a raw number that should run through Intl.NumberFormat. */
numeric: boolean
label: string
sub: string
Expand Down Expand Up @@ -90,21 +77,15 @@ export interface DossierData {
boards: DossierBoardLine[]
carryOver: DossierCarryOverCard[]
streak: DossierStreak
/** Initial value for the "line for tomorrow" text-area. */
lineForTomorrow: string
}

const DOSSIER_NUMBER_RE = /D-\d{4}-\d{2}-\d{2}-\d{3}/

/**
* Format the dossier serial as `D-YYYY-MM-DD-NNN`. `seq` defaults to 001;
* a real backend should hand in the per-day sequence number.
*/
export function formatDossierSerial(date: Date, seq = 1): string {
const { yyyy, mm, dd } = formatLocalDossierDateParts(date)
const nnn = seq.toString().padStart(3, '0')
const serial = `D-${yyyy}-${mm}-${dd}-${nnn}`
// Light invariant — useful in dev, harmless in prod.
if (!DOSSIER_NUMBER_RE.test(serial)) {
throw new Error(`Invalid dossier serial: ${serial}`)
}
Expand All @@ -124,7 +105,47 @@ function formatLocalDossierDateParts(date: Date): { yyyy: string; mm: string; dd
}
}

/** Stub data the surface falls back to when the backend is silent. */
function formatUtcTime(iso: string | null): string {
if (!iso) return '--:--'
const d = new Date(iso)
return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
}

function mapCadenceResponse(response: CadenceApiResponse): DossierCadence {
const weights = Array.from({ length: 24 }, (_, i) => {
const bucket = response.buckets.find((b) => b.hour === i)
return bucket?.eventCount ?? 0
})

const peakHourIndex = response.peakHour ?? 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve null peak hour instead of defaulting to 00:00

When the backend returns an empty cadence day (peakHour: null), this fallback sets peakHourIndex to 0, so TodayCadence highlights midnight as the peak bar even though peakAction says no peak. This creates contradictory and misleading UI on no-activity days. Keep the "no peak" state through to rendering (e.g., nullable/sentinel peak index) so no bar is emphasized when there is no peak hour.

Useful? React with 👍 / 👎.


const firstTime = formatUtcTime(response.firstActionAt)
const lastTime = formatUtcTime(response.lastActionAt)

const peakEvents = weights[peakHourIndex] ?? 0
const peakAction = response.peakHour != null
? `${peakHourIndex.toString().padStart(2, '0')}:00-${((peakHourIndex + 1) % 24).toString().padStart(2, '0')}:00 UTC · ${peakEvents} events`
: 'no peak'

return {
weights,
peakHourIndex,
firstAction: `${firstTime} UTC · first action`,
peakAction,
lastAction: `${lastTime} UTC · last action`,
}
}
Comment thread
Chris0Jeky marked this conversation as resolved.

function mapStreakResponse(response: StreakApiResponse): DossierStreak {
const cells = response.days.map((d) => d.intensityBucket)
return {
cells,
todayIndex: cells.length - 1,
totalDays: response.currentStreakLength,
longestThisYear: response.longestStreakLength,
}
}

function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData {
const overdueCount = summary?.summary.overdueCards ?? 2
const capturesTriaged = 11
Expand Down Expand Up @@ -223,11 +244,9 @@ function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData
{ serial: 'C-061', title: 'Reply: design system intro', age: '1d overdue', reason: 'snoozed yesterday' },
]

// 90 deterministic streak cells — last cell is "today".
const cells = Array.from({ length: 90 }, (_, i) => {
if (i === 89) return 4
if (i === 73) return 0
// Deterministic pseudo-random based on index
return ((i * 31) % 5)
})

Expand Down Expand Up @@ -258,10 +277,15 @@ function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData
}

export interface UseTodayDossierOptions {
/** Override "now" for tests so dossier serial is deterministic. */
now?: Ref<Date> | Date
}

export interface SealDayResult {
sealed: boolean
alreadySealed: boolean
inProgress?: boolean
}

export function useTodayDossier(options: UseTodayDossierOptions = {}) {
const workspace = useWorkspaceStore()
const fixedNow = options.now instanceof Date ? options.now : null
Expand Down Expand Up @@ -294,15 +318,134 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) {
})

const sealed = ref(false)
const liveCadence = ref<DossierCadence | null>(null)
const liveStreak = ref<DossierStreak | null>(null)
const liveLineForTomorrow = ref('')

const stubDossier = computed<DossierData>(() => buildStubDossier(now.value, workspace.todaySummary))

const dossier = computed<DossierData>(() => {
const base = stubDossier.value
return {
...base,
cadence: liveCadence.value ?? base.cadence,
streak: liveStreak.value ?? base.streak,
lineForTomorrow: liveLineForTomorrow.value,
}
})

let autosaveTimer: ReturnType<typeof setTimeout> | null = null
let tomorrowNoteMutationGeneration = 0
let pendingAutosave: {
text: string
dateStr: string
resolve: () => void
reject: (error: unknown) => void
} | null = null
const AUTOSAVE_DEBOUNCE_MS = 800

async function flushAutosave() {
const pending = pendingAutosave
if (!pending) return

pendingAutosave = null
try {
await todayApi.saveTomorrowNote(pending.dateStr, pending.text)
pending.resolve()
} catch (err) {
logError('Tomorrow note autosave failed', { message: (err as Error)?.message })
pending.reject(err)
}
}

function saveLineForTomorrow(text: string, dateStr = formatLocalDossierDate(now.value)): Promise<void> {
tomorrowNoteMutationGeneration += 1
liveLineForTomorrow.value = text
if (pendingAutosave) {
pendingAutosave.reject(new Error('Superseded by newer tomorrow note autosave'))
}
Comment thread
Chris0Jeky marked this conversation as resolved.
if (autosaveTimer) clearTimeout(autosaveTimer)
return new Promise((resolve, reject) => {
pendingAutosave = { text, dateStr, resolve, reject }
autosaveTimer = setTimeout(() => {
autosaveTimer = null
void flushAutosave()
}, AUTOSAVE_DEBOUNCE_MS)
})
}

onScopeDispose(() => {
if (autosaveTimer) {
clearTimeout(autosaveTimer)
autosaveTimer = null
void flushAutosave()
}
})

let fetchGeneration = 0

const dossier = computed<DossierData>(() => buildStubDossier(now.value, workspace.todaySummary))
async function fetchLiveData() {
const gen = ++fetchGeneration
const dateStr = formatLocalDossierDate(now.value)
const tomorrowNoteMutationGenerationAtFetch = tomorrowNoteMutationGeneration

function sealDay(): { sealed: boolean; alreadySealed: boolean } {
const results = await Promise.allSettled([
todayApi.getCadence(dateStr),
todayApi.getStreak(90),
todayApi.getSealStatus(dateStr),
todayApi.getTomorrowNote(dateStr),
])

if (gen !== fetchGeneration) return

if (results[0].status === 'fulfilled') {
liveCadence.value = mapCadenceResponse(results[0].value)
}
if (results[1].status === 'fulfilled') {
liveStreak.value = mapStreakResponse(results[1].value)
}
if (results[2].status === 'fulfilled') {
sealed.value = results[2].value.isSealed
}
if (tomorrowNoteMutationGenerationAtFetch === tomorrowNoteMutationGeneration) {
if (results[3].status === 'fulfilled') {
liveLineForTomorrow.value = results[3].value?.text ?? ''
} else {
liveLineForTomorrow.value = ''
}
}
}

watch(now, () => {
liveCadence.value = null
liveStreak.value = null
liveLineForTomorrow.value = ''
tomorrowNoteMutationGeneration += 1
sealed.value = false
fetchLiveData()
}, { immediate: true })

let sealingInProgress = false

async function sealDay(): Promise<SealDayResult> {
if (sealed.value) {
return { sealed: true, alreadySealed: true }
}
sealed.value = true
return { sealed: true, alreadySealed: false }
if (sealingInProgress) {
return { sealed: false, alreadySealed: false, inProgress: true }
}

sealingInProgress = true
try {
const dateStr = formatLocalDossierDate(now.value)
const response = await todayApi.sealDay(dateStr)
sealed.value = true
return { sealed: true, alreadySealed: response.wasAlreadySealed }
} catch {
return { sealed: false, alreadySealed: false }
} finally {
sealingInProgress = false
}
Comment thread
Chris0Jeky marked this conversation as resolved.
}

function resetSealForTesting() {
Expand All @@ -313,6 +456,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) {
dossier,
sealed,
sealDay,
saveLineForTomorrow,
Comment thread
Chris0Jeky marked this conversation as resolved.
resetSealForTesting,
}
}
Loading
Loading