288 lines
No EOL
10 KiB
Vue
288 lines
No EOL
10 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import * as api from '@/api'
|
|
import CostDisplay from '@/components/CostDisplay.vue'
|
|
import type { TodayResponseV11, TodayDeadline, EmailSuggestion, NotificationLogEntry, SuggestionClassification } from '@/types'
|
|
|
|
const toast = useToastStore()
|
|
const router = useRouter()
|
|
|
|
const today = ref<TodayResponseV11 | null>(null)
|
|
const loading = ref(true)
|
|
const deadlines = ref<TodayDeadline[]>([])
|
|
const suggestions = ref<EmailSuggestion[]>([])
|
|
const notifications = ref<NotificationLogEntry[]>([])
|
|
const suggestionActioningId = ref<string | null>(null)
|
|
|
|
const nudgeIds = computed(() => new Set(today.value?.nudges.map((n) => n.application_id) ?? []))
|
|
|
|
const pendingSuggestions = computed(() =>
|
|
suggestions.value.filter((s) => s.status === 'pending')
|
|
)
|
|
|
|
const classificationChipClass: Record<SuggestionClassification, string> = {
|
|
interview_invite: 'bg-green-100 text-green-800',
|
|
rejection: 'bg-red-100 text-red-800',
|
|
question: 'bg-yellow-100 text-yellow-800',
|
|
noise: 'bg-gray-100 text-gray-600'
|
|
}
|
|
|
|
const classificationLabel: Record<SuggestionClassification, string> = {
|
|
interview_invite: 'Interview Invite',
|
|
rejection: 'Rejection',
|
|
question: 'Question',
|
|
noise: 'Noise'
|
|
}
|
|
|
|
function daysUntil(dateStr: string): number {
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
const target = new Date(dateStr)
|
|
target.setHours(0, 0, 0, 0)
|
|
const diff = Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
|
return diff
|
|
}
|
|
|
|
function isUrgent(dateStr: string): boolean {
|
|
return daysUntil(dateStr) <= 2
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
async function loadToday() {
|
|
try {
|
|
const [todayRes, suggestionsRes, notifRes] = await Promise.allSettled([
|
|
api.getToday(),
|
|
api.getSuggestions(),
|
|
api.getNotificationLog()
|
|
])
|
|
if (todayRes.status === 'fulfilled') {
|
|
today.value = todayRes.value
|
|
deadlines.value = todayRes.value.deadlines ?? []
|
|
}
|
|
if (suggestionsRes.status === 'fulfilled') {
|
|
suggestions.value = suggestionsRes.value
|
|
}
|
|
if (notifRes.status === 'fulfilled') {
|
|
notifications.value = notifRes.value.slice(0, 5)
|
|
}
|
|
} catch {
|
|
toast.push('Failed to load today digest', 'error')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function acceptSuggestion(id: string) {
|
|
suggestionActioningId.value = id
|
|
try {
|
|
await api.acceptSuggestion(id)
|
|
suggestions.value = suggestions.value.map((s) =>
|
|
s.id === id ? { ...s, status: 'accepted' } : s
|
|
)
|
|
toast.push('Suggestion accepted', 'success')
|
|
} catch {
|
|
toast.push('Failed to accept suggestion', 'error')
|
|
} finally {
|
|
suggestionActioningId.value = null
|
|
}
|
|
}
|
|
|
|
async function dismissSuggestion(id: string) {
|
|
suggestionActioningId.value = id
|
|
try {
|
|
await api.dismissSuggestion(id)
|
|
suggestions.value = suggestions.value.map((s) =>
|
|
s.id === id ? { ...s, status: 'dismissed' } : s
|
|
)
|
|
toast.push('Suggestion dismissed', 'success')
|
|
} catch {
|
|
toast.push('Failed to dismiss suggestion', 'error')
|
|
} finally {
|
|
suggestionActioningId.value = null
|
|
}
|
|
}
|
|
|
|
function goToApplication(id: string) {
|
|
router.push(`/applications/${id}`)
|
|
}
|
|
|
|
function copyNudge(suggestion: string) {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(suggestion).then(
|
|
() => toast.push('Follow-up draft copied to clipboard', 'success'),
|
|
() => toast.push('Copy failed', 'error')
|
|
)
|
|
} else {
|
|
toast.push('Clipboard not available', 'error')
|
|
}
|
|
}
|
|
|
|
onMounted(loadToday)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<h1 class="text-2xl font-bold">Today</h1>
|
|
|
|
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
|
|
<template v-if="!loading && today">
|
|
<!-- Pending approvals banner -->
|
|
<div v-if="today.pending_approvals > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<span class="font-medium text-yellow-800">
|
|
{{ today.pending_approvals }} pending approval{{ today.pending_approvals > 1 ? 's' : '' }} waiting for you.
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Deadlines this week strip -->
|
|
<section v-if="deadlines.length > 0">
|
|
<h2 class="font-semibold text-lg mb-3">Deadlines This Week</h2>
|
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div
|
|
v-for="d in deadlines"
|
|
:key="d.application_id"
|
|
class="rounded-lg border p-4 cursor-pointer hover:shadow-md transition-shadow"
|
|
:class="isUrgent(d.apply_by) ? 'bg-red-50 border-red-300' : 'bg-white border-gray-200'"
|
|
@click="goToApplication(d.application_id)"
|
|
>
|
|
<div class="font-medium">{{ d.title }}</div>
|
|
<div class="text-sm text-gray-600">{{ d.company }}</div>
|
|
<div
|
|
class="mt-2 text-sm font-medium"
|
|
:class="isUrgent(d.apply_by) ? 'text-red-700' : 'text-gray-600'"
|
|
>
|
|
Apply by {{ formatDate(d.apply_by) }}
|
|
<span v-if="daysUntil(d.apply_by) === 0" class="ml-1">(today)</span>
|
|
<span v-else-if="daysUntil(d.apply_by) === 1" class="ml-1">(tomorrow)</span>
|
|
<span v-else class="ml-1">({{ daysUntil(d.apply_by) }} days)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Inbox insights strip -->
|
|
<section v-if="pendingSuggestions.length > 0">
|
|
<h2 class="font-semibold text-lg mb-3">Inbox Insights</h2>
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="s in pendingSuggestions"
|
|
:key="s.id"
|
|
class="bg-white rounded-lg border border-gray-200 p-4"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700">{{ s.from_address }}</span>
|
|
<span
|
|
class="text-xs rounded px-2 py-0.5 font-medium"
|
|
:class="classificationChipClass[s.classification]"
|
|
>
|
|
{{ classificationLabel[s.classification] }}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="text-sm bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 disabled:opacity-50"
|
|
:disabled="suggestionActioningId === s.id"
|
|
data-testid="accept-suggestion"
|
|
@click.stop="acceptSuggestion(s.id)"
|
|
>
|
|
Accept
|
|
</button>
|
|
<button
|
|
class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded hover:bg-gray-300 disabled:opacity-50"
|
|
:disabled="suggestionActioningId === s.id"
|
|
data-testid="dismiss-suggestion"
|
|
@click.stop="dismissSuggestion(s.id)"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="font-medium text-sm mt-2">{{ s.subject }}</div>
|
|
<div class="text-sm text-gray-500 mt-1">{{ s.snippet }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Digest cards -->
|
|
<section>
|
|
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
|
|
<div v-if="today.digest.length === 0" class="text-gray-400 text-sm">No postings in your digest yet.</div>
|
|
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div
|
|
v-for="item in today.digest"
|
|
:key="item.application_id"
|
|
class="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow"
|
|
@click="goToApplication(item.application_id)"
|
|
>
|
|
<div class="font-medium">{{ item.title }}</div>
|
|
<div class="text-sm text-gray-600">{{ item.company }}</div>
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<span class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5 font-medium">
|
|
Score: {{ item.score }}
|
|
</span>
|
|
<span
|
|
v-if="nudgeIds.has(item.application_id)"
|
|
class="inline-block w-2 h-2 rounded-full bg-orange-500"
|
|
title="Follow-up nudge pending"
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Nudge cards -->
|
|
<section>
|
|
<h2 class="font-semibold text-lg mb-3">Follow-up Nudges</h2>
|
|
<div v-if="today.nudges.length === 0" class="text-gray-400 text-sm">No nudges. You are up to date.</div>
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="nudge in today.nudges"
|
|
:key="nudge.application_id"
|
|
class="bg-orange-50 border border-orange-200 rounded-lg p-4"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<span class="font-medium text-orange-900">
|
|
Sent {{ nudge.days_since_sent }} days ago
|
|
</span>
|
|
<button
|
|
class="text-sm text-indigo-600 hover:underline"
|
|
@click="goToApplication(nudge.application_id)"
|
|
>
|
|
Open application
|
|
</button>
|
|
</div>
|
|
<p class="text-sm text-gray-700 mt-2">{{ nudge.suggestion }}</p>
|
|
<button
|
|
class="mt-2 text-sm bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700"
|
|
@click="copyNudge(nudge.suggestion)"
|
|
>
|
|
Copy follow-up draft
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Notification mini-log -->
|
|
<section v-if="notifications.length > 0">
|
|
<h2 class="font-semibold text-lg mb-3">Recent Notifications</h2>
|
|
<ul class="text-sm space-y-1 bg-white rounded-lg border border-gray-200 p-3">
|
|
<li v-for="n in notifications" :key="n.id" class="border-b border-gray-100 py-1 last:border-0">
|
|
<span class="text-gray-400 text-xs">{{ n.created_at?.slice(0, 16).replace('T', ' ') }}</span>
|
|
<span class="ml-2 text-gray-700">{{ n.message }}</span>
|
|
<span class="ml-2 text-xs text-gray-400">({{ n.channel }})</span>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- Cost display -->
|
|
<CostDisplay />
|
|
</template>
|
|
</div>
|
|
</template> |