feat(diary): redesign summary page as daily review and add note pinch-zoom, line spacing

- 첫 페이지를 '하루 정리' 화면으로 개편: 기록 유무와 무관한 24시간 고정
  타임테이블(trimHours 옵션), 그 날짜에 진행 중이던 목표들의 다짐별 진행
  현황 카드(하루 다짐은 그날 실적, 주간/월간/기간 다짐은 그 날짜까지 누적,
  '이하 유지'는 한도 게이지로 구분), 기록 상세·통계 막대 재배치
- 노트 페이지: UIScrollView 기반 핀치줌(두 손가락, fit~4배, 배치 모드에선 잠금)
- 줄 노트 줄 간격 설정(좁게/보통/넓게/아주 넓게, 페이지별 저장·내보내기 반영)
- 새 문자열 en/ja 번역 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 03:46:58 +09:00
parent 1f77264852
commit ecb7b43d31
7 changed files with 745 additions and 71 deletions

View File

@ -127,6 +127,50 @@
}
}
},
"%@ / %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%1$@ / %2$@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%1$@ / %2$@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ / %2$@"
}
}
}
},
"%@ / 한도 %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%1$@ / limit %2$@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%1$@ / 上限%2$@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ / 한도 %2$@"
}
}
}
},
"%@ ~ %@": {
"localizations": {
"en": {
@ -477,24 +521,28 @@
}
}
},
"%lld개 진행 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld in progress"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld件進行中"
}
}
}
},
"%lld건": {
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "%lld entry"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "%lld entries"
}
}
}
"stringUnit": {
"state": "translated",
"value": "%lld"
}
},
"ja": {
@ -1308,6 +1356,22 @@
}
}
},
"기간 목표 · 이 날까지 누적": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Period goal · total up to this day"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "期間目標・この日までの累計"
}
}
}
},
"기간 진행률 %@": {
"localizations": {
"en": {
@ -1970,6 +2034,22 @@
}
}
},
"넓게": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Wide"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "広い"
}
}
}
},
"누적 기록": {
"localizations": {
"en": {
@ -2322,6 +2402,22 @@
}
}
},
"다짐이 아직 없는 목표예요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This goal has no quests yet"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "まだクエストのない目標です"
}
}
}
},
"다짐이 없는 목표예요. 달성했나요?": {
"localizations": {
"en": {
@ -3808,6 +3904,22 @@
}
}
},
"보통": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Normal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "標準"
}
}
}
},
"부터": {
"localizations": {
"en": {
@ -4192,6 +4304,22 @@
}
}
},
"설정된 기간 밖이에요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Outside the set period"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "設定期間の範囲外です"
}
}
}
},
"설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.": {
"localizations": {
"en": {
@ -4673,6 +4801,22 @@
}
}
},
"아주 넓게": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Extra wide"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "とても広い"
}
}
}
},
"아직 등록된 행동이 없어요": {
"localizations": {
"en": {
@ -4690,6 +4834,7 @@
}
},
"아직 오늘의 기록이 없어요": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -5186,6 +5331,22 @@
}
}
},
"월간 목표 · 이 날까지 누적": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Monthly goal · total up to this day"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "月間目標・この日までの累計"
}
}
}
},
"월간 시간 합계": {
"localizations": {
"en": {
@ -5266,6 +5427,70 @@
}
}
},
"이 날은 수행일이 아니에요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Not scheduled for this day"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この日は実行日ではありません"
}
}
}
},
"이 날의 기록이 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No records on this day"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この日の記録はありません"
}
}
}
},
"이 날의 목표": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This Day's Goals"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この日の目標"
}
}
}
},
"이 날짜에 진행 중인 목표가 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No goals in progress on this date"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この日付に進行中の目標はありません"
}
}
}
},
"이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.": {
"localizations": {
"en": {
@ -5907,6 +6132,22 @@
}
}
},
"좁게": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Narrow"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "狭い"
}
}
}
},
"종료": {
"localizations": {
"en": {
@ -6083,6 +6324,22 @@
}
}
},
"주간 목표 · 이 날까지 누적": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Weekly goal · total up to this day"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "週間目標・この日までの累計"
}
}
}
},
"주간 시간 합계": {
"localizations": {
"en": {
@ -6195,6 +6452,22 @@
}
}
},
"줄 간격": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Line spacing"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "行間"
}
}
}
},
"줄 노트": {
"localizations": {
"en": {
@ -7173,6 +7446,22 @@
}
}
},
"하루 목표": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Daily goal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1日の目標"
}
}
}
},
"하루 시작 시간": {
"localizations": {
"en": {

View File

@ -389,7 +389,7 @@ struct DiaryPrintNotePageView: View {
ZStack {
Rectangle().fill(AppTheme.surface)
if page.lined {
DiaryLinedPaper()
DiaryLinedPaper(spacing: page.lineSpacing)
}
ForEach(page.items, id: \.uuid) { item in
DiaryItemView(

View File

@ -38,13 +38,12 @@ struct DiaryNotePageView: View {
var body: some View {
VStack(spacing: 10) {
pageToolbar
GeometryReader { geo in
let scale = min(geo.size.width / DiaryPageMetrics.size.width,
geo.size.height / DiaryPageMetrics.size.height)
// ( 4), .
// / · .
// .
DiaryZoomContainer(pageSize: DiaryPageMetrics.size, zoomEnabled: !arranging) {
pageContent
.frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height)
.scaleEffect(scale)
.frame(width: geo.size.width, height: geo.size.height)
}
}
.padding(.horizontal, 24)
@ -71,6 +70,22 @@ struct DiaryNotePageView: View {
.toggleStyle(.button)
.onChange(of: page.lined) { save() }
if page.lined {
Menu {
Picker("줄 간격", selection: Binding(
get: { page.lineSpacing },
set: { page.lineSpacing = $0; save() }
)) {
Text("좁게").tag(34.0)
Text("보통").tag(44.0)
Text("넓게").tag(56.0)
Text("아주 넓게").tag(70.0)
}
} label: {
Label("줄 간격", systemImage: "arrow.up.and.down.text.horizontal")
}
}
PhotosPicker(selection: $photoSelection, matching: .images) {
Label("사진", systemImage: "photo.badge.plus")
}
@ -126,7 +141,7 @@ struct DiaryNotePageView: View {
.fill(AppTheme.surface)
.shadow(color: .black.opacity(0.10), radius: 14, y: 6)
if page.lined {
DiaryLinedPaper()
DiaryLinedPaper(spacing: page.lineSpacing)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ -200,20 +215,118 @@ struct DiaryNotePageView: View {
// MARK: -
struct DiaryLinedPaper: View {
/// ( , 44pt)
var spacing: Double = DiaryPageMetrics.lineSpacing
var body: some View {
Canvas { canvasContext, size in
let step = max(CGFloat(spacing), 20)
var y = DiaryPageMetrics.lineTopInset
while y < size.height - 24 {
var path = Path()
path.move(to: CGPoint(x: 32, y: y))
path.addLine(to: CGPoint(x: size.width - 32, y: y))
canvasContext.stroke(path, with: .color(.primary.opacity(0.10)), lineWidth: 1)
y += DiaryPageMetrics.lineSpacing
y += step
}
}
}
}
// MARK: - (UIScrollView )
/// / .
/// - (fit) , 4
/// - ( · )
/// - zoomEnabled=false
private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
let pageSize: CGSize
let zoomEnabled: Bool
@ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> DiaryZoomScrollView {
let scroll = DiaryZoomScrollView()
scroll.pageSize = pageSize
scroll.delegate = context.coordinator
scroll.showsVerticalScrollIndicator = false
scroll.showsHorizontalScrollIndicator = false
scroll.contentInsetAdjustmentBehavior = .never
scroll.scrollsToTop = false
scroll.bounces = false
scroll.bouncesZoom = true
scroll.backgroundColor = .clear
// ,
scroll.panGestureRecognizer.minimumNumberOfTouches = 2
let hosting = UIHostingController(rootView: content())
hosting.view.backgroundColor = .clear
hosting.safeAreaRegions = []
hosting.view.frame = CGRect(origin: .zero, size: pageSize)
scroll.addSubview(hosting.view)
scroll.contentSize = pageSize
scroll.zoomedView = hosting.view
context.coordinator.hosting = hosting
return scroll
}
func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) {
context.coordinator.hosting?.rootView = content()
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
}
final class Coordinator: NSObject, UIScrollViewDelegate {
var hosting: UIHostingController<Content>?
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
(scrollView as? DiaryZoomScrollView)?.zoomedView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
}
}
}
/// (· ) fit
final class DiaryZoomScrollView: UIScrollView {
var pageSize: CGSize = DiaryPageMetrics.size
weak var zoomedView: UIView?
private var didSetInitialZoom = false
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
let fit = min(bounds.width / pageSize.width, bounds.height / pageSize.height)
let wasAtMinimum = zoomScale <= minimumZoomScale + 0.0001
if abs(minimumZoomScale - fit) > 0.0001 {
minimumZoomScale = fit
maximumZoomScale = max(fit * 4, 1)
// fit fit
if !didSetInitialZoom || wasAtMinimum || zoomScale < fit {
zoomScale = fit
didSetInitialZoom = true
}
} else if !didSetInitialZoom {
zoomScale = fit
didSetInitialZoom = true
}
centerZoomedView()
}
///
func centerZoomedView() {
guard let zoomedView else { return }
let dx = max((bounds.width - contentSize.width) / 2, 0)
let dy = max((bounds.height - contentSize.height) / 2, 0)
zoomedView.center = CGPoint(x: contentSize.width / 2 + dx,
y: contentSize.height / 2 + dy)
}
}
// MARK: - (/)
struct DiaryItemView: View {

View File

@ -433,8 +433,12 @@ struct DiaryDetailView: View {
}
}
// MARK: - ( )
// MARK: - ( )
/// " " .
/// - 24 (trimHours: false)
/// - .
/// / " " .
private struct DiarySummaryPage: View {
@Bindable var entry: DiaryEntry
let showMood: Bool
@ -443,6 +447,7 @@ private struct DiarySummaryPage: View {
@Query private var sessions: [TimeSession]
@Query private var countEntries: [CountEntry]
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
@Query(sort: \Goal.sortOrder) private var allGoals: [Goal]
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
private var math: DayMath { DayMath() }
@ -451,7 +456,26 @@ private struct DiarySummaryPage: View {
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
}
/// ( + )
/// : , /
private var referenceNow: Date {
let range = math.dayRange(forKey: entry.dayKey)
return range.contains(.now) ? .now : range.upperBound.addingTimeInterval(-1)
}
/// ( & )
private var activeGoals: [Goal] {
let day = math.dayRange(forKey: entry.dayKey).lowerBound
let cal = math.calendar
return allGoals.filter { goal in
guard cal.startOfDay(for: goal.startDate) <= day else { return false }
if let end = goal.endDate {
return cal.startOfDay(for: end) >= day
}
return true
}
}
/// ( 24 + )
private var daySnapshot: ExportSnapshot {
ExportBuilder.history(
dayKey: entry.dayKey,
@ -460,7 +484,8 @@ private struct DiarySummaryPage: View {
entries: countEntries,
orderedActions: orderedActions,
excludedActionIDs: [],
math: math
math: math,
trimHours: false
)
}
@ -483,10 +508,9 @@ private struct DiarySummaryPage: View {
var body: some View {
GeometryReader { geo in
let wide = geo.size.width >= 720
let sideWidth: CGFloat = 340
let leftWidth = wide
? geo.size.width - sideWidth - 16 - 40
: geo.size.width - 40
let timetableWidth: CGFloat = 300
let rightWidth = geo.size.width - 40 - timetableWidth - 16
let fullWidth = geo.size.width - 40
ScrollView {
VStack(alignment: .leading, spacing: 14) {
@ -497,19 +521,35 @@ private struct DiarySummaryPage: View {
ExportHeroRow(stats: daySnapshot.hero)
if wide {
HStack(alignment: .top, spacing: 16) {
// : 24 ( )
VStack(spacing: 14) {
leftSections(innerWidth: leftWidth - 28)
timetableSections(innerWidth: timetableWidth - 28)
}
.frame(width: timetableWidth)
// : ·
VStack(spacing: 14) {
DiaryGoalsCard(
goals: activeGoals,
dayKey: entry.dayKey,
referenceNow: referenceNow,
math: math
)
DiaryTodoCard(entry: entry)
rightSections(innerWidth: sideWidth - 28)
recordSections(innerWidth: rightWidth - 28)
barSections(innerWidth: rightWidth - 28)
}
.frame(width: sideWidth)
}
} else {
DiaryGoalsCard(
goals: activeGoals,
dayKey: entry.dayKey,
referenceNow: referenceNow,
math: math
)
DiaryTodoCard(entry: entry)
leftSections(innerWidth: leftWidth - 28)
rightSections(innerWidth: leftWidth - 28)
timetableSections(innerWidth: fullWidth - 28)
recordSections(innerWidth: fullWidth - 28)
barSections(innerWidth: fullWidth - 28)
}
}
.padding(20)
@ -533,34 +573,242 @@ private struct DiarySummaryPage: View {
}
}
/// () : +
@ViewBuilder
private func leftSections(innerWidth: CGFloat) -> some View {
private func timetableSections(innerWidth: CGFloat) -> some View {
ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in
if case .timetable = section {
ExportSectionView(section: section, innerWidth: innerWidth)
}
}
ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in
ExportSectionView(section: section, innerWidth: innerWidth)
}
@ViewBuilder
private func recordSections(innerWidth: CGFloat) -> some View {
let recordSections = daySnapshot.sections.filter {
if case .records = $0 { return true }
return false
}
if daySnapshot.sections.isEmpty && statBars.isEmpty {
Text("아직 오늘의 기록이 없어요")
if recordSections.isEmpty {
Text("이 날의 기록이 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.padding(.vertical, 30)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
} else {
ForEach(Array(recordSections.enumerated()), id: \.offset) { _, section in
ExportSectionView(section: section, innerWidth: innerWidth)
}
}
}
/// () :
@ViewBuilder
private func rightSections(innerWidth: CGFloat) -> some View {
ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in
if case .records = section {
ExportSectionView(section: section, innerWidth: innerWidth)
private func barSections(innerWidth: CGFloat) -> some View {
ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in
ExportSectionView(section: section, innerWidth: innerWidth)
}
}
}
// MARK: - ·
/// .
/// - : /, " "
/// - // : " "
/// - ' ' (, )
private struct DiaryGoalsCard: View {
let goals: [Goal]
let dayKey: Date
let referenceNow: Date
let math: DayMath
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("이 날의 목표")
.font(.subheadline.weight(.semibold))
Spacer()
Text(String(localized: "\(goals.count)개 진행 중"))
.font(.caption2)
.foregroundStyle(.secondary)
}
if goals.isEmpty {
Text("이 날짜에 진행 중인 목표가 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
} else {
ForEach(Array(goals.enumerated()), id: \.element.uuid) { index, goal in
if index > 0 {
Divider()
}
goalBlock(goal)
}
}
}
.padding(14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// MARK: ( + )
@ViewBuilder
private func goalBlock(_ goal: Goal) -> some View {
let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow)
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(goal.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(goal.title)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 6)
Text("\(Int((dayRatio * 100).rounded()))%")
.font(.footnote.weight(.bold).monospacedDigit())
.foregroundStyle(goal.color)
}
// ( )
gaugeBar(ratio: dayRatio, color: goal.color)
if goal.sortedQuests.isEmpty {
Text("다짐이 아직 없는 목표예요")
.font(.caption2)
.foregroundStyle(.tertiary)
} else {
ForEach(goal.sortedQuests, id: \.uuid) { quest in
questRow(quest)
}
}
}
}
private func gaugeBar(ratio: Double, color: Color, height: CGFloat = 6) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(color.opacity(0.15))
Capsule()
.fill(color)
.frame(width: max(geo.size.width * min(max(ratio, 0), 1), ratio > 0 ? height : 0))
}
}
.frame(height: height)
}
// MARK:
private struct QuestStat {
var contextLabel: String
var value: Double?
var achieved: Bool
}
@ViewBuilder
private func questRow(_ quest: Quest) -> some View {
let stat = questStat(quest)
let target = quest.targetValue
let limit = quest.direction == .atMost
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: quest.targetSymbol)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(quest.targetName)
.font(.caption.weight(.medium))
.lineLimit(1)
Text(stat.contextLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
Spacer(minLength: 4)
if let value = stat.value {
Group {
if limit {
Text("\(valueLabel(value, quest: quest)) / 한도 \(quest.targetValueLabel)")
} else {
Text("\(valueLabel(value, quest: quest)) / \(quest.targetValueLabel)")
}
}
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
if stat.achieved {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(AppTheme.green)
} else if limit {
Image(systemName: "exclamationmark.circle.fill")
.font(.caption)
.foregroundStyle(.red)
}
}
}
if let value = stat.value, target > 0 {
let ratio = value / target
gaugeBar(
ratio: ratio,
color: limit ? (ratio > 1 ? .red : AppTheme.yellow) : quest.targetColor,
height: 4
)
}
}
}
.opacity(stat.value == nil ? 0.55 : 1)
}
/// " " . value == nil ( / ).
private func questStat(_ quest: Quest) -> QuestStat {
let progress = QuestProgress(quest: quest, math: math)
let dayRange = math.dayRange(forKey: dayKey)
let measured: Range<Date>?
let context: String
switch quest.period {
case .daily:
if progress.isActiveDay(dayKey) {
measured = dayRange
context = String(localized: "하루 목표")
} else {
measured = nil
context = String(localized: "이 날은 수행일이 아니에요")
}
case .weekly:
let week = math.weekRange(containing: dayRange.lowerBound)
measured = week.lowerBound..<min(dayRange.upperBound, week.upperBound)
context = String(localized: "주간 목표 · 이 날까지 누적")
case .monthly:
let month = math.monthRange(containing: dayRange.lowerBound)
measured = month.lowerBound..<min(dayRange.upperBound, month.upperBound)
context = String(localized: "월간 목표 · 이 날까지 누적")
case .custom:
if let full = progress.currentPeriodRange(now: referenceNow), full.overlaps(dayRange) {
measured = full.lowerBound..<min(dayRange.upperBound, full.upperBound)
context = String(localized: "기간 목표 · 이 날까지 누적")
} else {
measured = nil
context = String(localized: "설정된 기간 밖이에요")
}
}
guard let measured else {
return QuestStat(contextLabel: context, value: nil, achieved: false)
}
let value = progress.value(in: measured, now: referenceNow)
let achieved: Bool
switch quest.direction {
case .atLeast: achieved = value >= quest.targetValue
case .atMost: achieved = value <= quest.targetValue
}
return QuestStat(contextLabel: context, value: value, achieved: achieved)
}
private func valueLabel(_ value: Double, quest: Quest) -> String {
switch quest.measure {
case .time: return Format.durationShort(value)
case .count: return String(localized: "\(Int(value))")
}
}
}

View File

@ -203,7 +203,8 @@ enum ExportBuilder {
entries: [CountEntry],
orderedActions: [Action],
excludedActionIDs: Set<PersistentIdentifier>,
math: DayMath
math: DayMath,
trimHours: Bool = true
) -> ExportSnapshot {
let included = orderedActions.filter { !excludedActionIDs.contains($0.persistentModelID) }
let allowed = Set(included.map(\.persistentModelID))
@ -220,7 +221,8 @@ enum ExportBuilder {
var sections: [ExportSectionData] = []
if let (table, subtitle) = timetable(dayKeys: keys, emphasizedKey: dayKey, showDayLabels: true,
sessions: sessions, entries: entries,
allowed: allowed, math: math, now: now) {
allowed: allowed, math: math, now: now,
trimHours: trimHours) {
sections.append(.timetable(title: String(localized: "주간 타임테이블"), subtitle: subtitle, table))
}
if let bars = actionTotalBars(segments: segments, counts: counts, type: .time) {
@ -251,7 +253,8 @@ enum ExportBuilder {
var sections: [ExportSectionData] = []
if let (table, subtitle) = timetable(dayKeys: [dayKey], emphasizedKey: nil, showDayLabels: false,
sessions: sessions, entries: entries,
allowed: allowed, math: math, now: now) {
allowed: allowed, math: math, now: now,
trimHours: trimHours) {
sections.append(.timetable(title: String(localized: "타임테이블"), subtitle: subtitle, table))
}
if !segments.isEmpty {
@ -352,7 +355,8 @@ enum ExportBuilder {
private static func timetable(
dayKeys: [Date], emphasizedKey: Date?, showDayLabels: Bool,
sessions: [TimeSession], entries: [CountEntry],
allowed: Set<PersistentIdentifier>, math: DayMath, now: Date
allowed: Set<PersistentIdentifier>, math: DayMath, now: Date,
trimHours: Bool = true
) -> (ExportTimetableData, subtitle: String?)? {
guard let firstKey = dayKeys.first else { return nil }
var days: [ExportTimetableData.Day] = []
@ -394,6 +398,13 @@ enum ExportBuilder {
))
}
let startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound)
// : 24
if !trimHours {
return (ExportTimetableData(startHour: startHour, hourLo: 0, hourHi: 24, days: days), nil)
}
guard minFrac.isFinite else { return nil }
// ( 6 )
@ -403,7 +414,6 @@ enum ExportBuilder {
hourHi = min(24, hourLo + 6)
hourLo = max(0, hourHi - 6)
}
let startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound)
let subtitle: String? = (hourLo > 0 || hourHi < 24)
? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):00 ~ \(String(format: "%02d", (startHour + hourHi) % 24)):00 구간만 표시")
: nil

View File

@ -76,6 +76,8 @@ final class DiaryPage {
var index: Int = 0
/// true = , false =
var lined: Bool = false
/// (pt, 768×1024 )
var lineSpacing: Double = 44
/// PKDrawing.dataRepresentation()
@Attribute(.externalStorage)
var drawingData: Data = Data()

View File

@ -1,30 +1,42 @@
{
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"comment": "위젯 익스텐션 표시 이름",
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Haru Danim"
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "위젯 익스텐션 표시 이름",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Haru Danim"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Haru Danim"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Haru Danim"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "하루 다님"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "하루 다님"
}
}
}
},
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "Haru_DanimWidgets"
}
}
}
}
},
"version": "1.0"
"version" : "1.0"
}