Files
cv-site/internal/handlers/cv.go
T
juanatsap e572af0771 feat: implement dynamic date calculation for projects
- Remove hardcoded startDate from La Porra project
- Add gitRepoUrl field to Project struct for dynamic date fetching
- Implement backend logic to fetch first commit date from git repositories
- Add processProjectDates function to calculate dates dynamically
- Update template to display computed dates and dynamic "Present/Presente"
- Add support for both static and git-based project start dates

When a project has a gitRepoUrl, the system automatically fetches the first
commit date from the repository. For current projects, it displays
"Present" (English) or "Presente" (Spanish) dynamically from the backend.

The La Porra project now uses git repository path for date calculation
instead of hardcoded JSON values.
2025-11-09 02:43:40 +00:00

415 lines
10 KiB
Go

package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/juanatsap/cv-site/internal/models"
"github.com/juanatsap/cv-site/internal/pdf"
"github.com/juanatsap/cv-site/internal/templates"
)
// CVHandler handles CV-related requests
type CVHandler struct {
templates *templates.Manager
pdfGenerator *pdf.Generator
serverAddr string
}
// NewCVHandler creates a new CV handler
func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler {
return &CVHandler{
templates: tmpl,
pdfGenerator: pdf.NewGenerator(30 * time.Second),
serverAddr: serverAddr,
}
}
// Home renders the full CV page
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter, default to English
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return
}
// Load CV data
cv, err := models.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Calculate duration for each experience
for i := range cv.Experience {
cv.Experience[i].Duration = calculateDuration(
cv.Experience[i].StartDate,
cv.Experience[i].EndDate,
cv.Experience[i].Current,
lang,
)
}
// Process projects for dynamic dates
for i := range cv.Projects {
processProjectDates(&cv.Projects[i], lang)
}
// Split skills between left and right sidebars
skillsLeft, skillsRight := splitSkills(cv.Skills.Technical)
// Calculate years of experience
yearsOfExperience := calculateYearsOfExperience()
// Get current year
currentYear := time.Now().Year()
// Prepare template data
data := map[string]interface{}{
"CV": cv,
"Lang": lang,
"SkillsLeft": skillsLeft,
"SkillsRight": skillsRight,
"YearsOfExperience": yearsOfExperience,
"CurrentYear": currentYear,
}
// Render template
tmpl, err := h.templates.Render("index.html")
if err != nil {
HandleError(w, r, TemplateError(err, "index.html"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
HandleError(w, r, TemplateError(err, "index.html"))
return
}
}
// CVContent renders just the CV content for HTMX swaps
func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return
}
// Load CV data
cv, err := models.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Calculate duration for each experience
for i := range cv.Experience {
cv.Experience[i].Duration = calculateDuration(
cv.Experience[i].StartDate,
cv.Experience[i].EndDate,
cv.Experience[i].Current,
lang,
)
}
// Process projects for dynamic dates
for i := range cv.Projects {
processProjectDates(&cv.Projects[i], lang)
}
// Split skills between left and right sidebars
skillsLeft, skillsRight := splitSkills(cv.Skills.Technical)
// Calculate years of experience
yearsOfExperience := calculateYearsOfExperience()
// Get current year
currentYear := time.Now().Year()
// Prepare template data
data := map[string]interface{}{
"CV": cv,
"Lang": lang,
"SkillsLeft": skillsLeft,
"SkillsRight": skillsRight,
"YearsOfExperience": yearsOfExperience,
"CurrentYear": currentYear,
}
// Render template
tmpl, err := h.templates.Render("cv-content.html")
if err != nil {
HandleError(w, r, TemplateError(err, "cv-content.html"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
HandleError(w, r, TemplateError(err, "cv-content.html"))
return
}
}
// ExportPDF handles PDF export requests using chromedp
func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return
}
// Construct URL to generate PDF from
// Use localhost instead of the actual server address to avoid network overhead
url := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
log.Printf("Generating PDF from URL: %s", url)
// Generate PDF
pdfData, err := h.pdfGenerator.GenerateFromURL(r.Context(), url)
if err != nil {
log.Printf("PDF generation failed: %v", err)
HandleError(w, r, InternalError(err))
return
}
// Set response headers
filename := fmt.Sprintf("CV-Juan-Andres-Moreno-Rubio-%s.pdf", lang)
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData)))
// Write PDF data
if _, err := w.Write(pdfData); err != nil {
log.Printf("Failed to write PDF response: %v", err)
return
}
log.Printf("Successfully generated PDF: %s (%d bytes)", filename, len(pdfData))
}
// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars
// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field
func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) {
if len(skills) == 0 {
return nil, nil
}
// Filter by sidebar field
for _, skill := range skills {
if skill.Sidebar == "right" {
right = append(right, skill)
} else {
// Default to left if not specified or if set to "left"
left = append(left, skill)
}
}
return left, right
}
// calculateYearsOfExperience calculates years of experience since April 1, 2005
// This matches the original React implementation that calculated from 01/04/2005
func calculateYearsOfExperience() int {
// First day at work: April 1, 2005
firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC)
// Current date
now := time.Now()
// Calculate the difference in years
years := now.Year() - firstDay.Year()
// Adjust if we haven't reached the anniversary this year yet
if now.Month() < firstDay.Month() ||
(now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) {
years--
}
return years
}
// calculateDuration calculates the duration between two dates in years and months
// Date format expected: "YYYY-MM" (e.g., "2021-01")
// Returns a formatted string like "3 years 6 months" or "6 months"
func calculateDuration(startDate, endDate string, current bool, lang string) string {
// Parse start date
start, err := time.Parse("2006-01", startDate)
if err != nil {
return ""
}
// Determine end date
var end time.Time
if current {
end = time.Now()
} else {
end, err = time.Parse("2006-01", endDate)
if err != nil {
return ""
}
}
// Calculate total months
totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month())
// If end date is before start date, return empty
if totalMonths < 0 {
return ""
}
years := totalMonths / 12
months := totalMonths % 12
// Format the duration string based on language
var result string
if lang == "es" {
if years > 0 && months > 0 {
yearStr := "años"
if years == 1 {
yearStr = "año"
}
monthStr := "meses"
if months == 1 {
monthStr = "mes"
}
result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr)
} else if years > 0 {
yearStr := "años"
if years == 1 {
yearStr = "año"
}
result = fmt.Sprintf("(%d %s)", years, yearStr)
} else {
monthStr := "meses"
if months == 1 {
monthStr = "mes"
}
result = fmt.Sprintf("(%d %s)", months, monthStr)
}
} else {
if years > 0 && months > 0 {
yearStr := "years"
if years == 1 {
yearStr = "year"
}
monthStr := "months"
if months == 1 {
monthStr = "month"
}
result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr)
} else if years > 0 {
yearStr := "years"
if years == 1 {
yearStr = "year"
}
result = fmt.Sprintf("(%d %s)", years, yearStr)
} else {
monthStr := "months"
if months == 1 {
monthStr = "month"
}
result = fmt.Sprintf("(%d %s)", months, monthStr)
}
}
return result
}
// processProjectDates calculates dynamic dates for projects
// If a project has a gitRepoUrl, it fetches the first commit date
// For current projects, it sets the current system date
func processProjectDates(project *models.Project, lang string) {
now := time.Now()
// Set dynamic current date for ongoing projects
if project.Current {
if lang == "es" {
project.DynamicDate = "Presente"
} else {
project.DynamicDate = "Present"
}
}
// If project has a git repository URL, fetch the first commit date
if project.GitRepoUrl != "" {
commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl)
if commitDate != "" {
project.ComputedStartDate = commitDate
}
}
// If no computed date and no static date, use current date for current projects
if project.ComputedStartDate == "" && project.StartDate == "" && project.Current {
project.ComputedStartDate = now.Format("2006-01")
}
// If we have a computed date but no static date, use the computed one
if project.ComputedStartDate != "" && project.StartDate == "" {
project.StartDate = project.ComputedStartDate
}
}
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
// Supports local git repository paths
func getGitRepoFirstCommitDate(repoPath string) string {
// Check if the path exists and is a directory
info, err := os.Stat(repoPath)
if err != nil || !info.IsDir() {
return ""
}
// Execute git command to get the first commit date
// Format: YYYY-MM (to match StartDate format)
cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return ""
}
// Parse the output to get the first commit date
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) == 0 {
return ""
}
// Extract YYYY-MM from the first commit timestamp
// Format of output: "2024-06-15 10:30:45 +0200"
firstLine := lines[0]
parts := strings.Fields(firstLine)
if len(parts) > 0 {
datePart := parts[0] // "2024-06-15"
dateParts := strings.Split(datePart, "-")
if len(dateParts) >= 2 {
return dateParts[0] + "-" + dateParts[1] // "2024-06"
}
}
return ""
}